diff --git a/.core_files.yaml b/.core_files.yaml index 2928d450ce2..55b543a333e 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -132,7 +132,7 @@ requirements: &requirements - homeassistant/package_constraints.txt - script/pip_check - requirements*.txt - - setup.cfg + - pyproject.toml any: - *base_platforms diff --git a/.coveragerc b/.coveragerc index 9e2c1e8c678..f52631a57bd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -210,7 +210,6 @@ omit = homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py homeassistant/components/deutsche_bahn/sensor.py - homeassistant/components/devolo_home_control/cover.py homeassistant/components/devolo_home_control/light.py homeassistant/components/devolo_home_control/sensor.py homeassistant/components/devolo_home_control/switch.py @@ -262,7 +261,9 @@ omit = homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py homeassistant/components/egardia/* - homeassistant/components/eight_sleep/* + homeassistant/components/eight_sleep/__init__.py + homeassistant/components/eight_sleep/binary_sensor.py + homeassistant/components/eight_sleep/sensor.py homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/__init__.py homeassistant/components/elkm1/alarm_control_panel.py @@ -407,6 +408,7 @@ omit = homeassistant/components/fritzbox_callmonitor/const.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py + homeassistant/components/frontier_silicon/const.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py @@ -504,16 +506,21 @@ omit = homeassistant/components/huawei_lte/switch.py homeassistant/components/hue/light.py homeassistant/components/hunterdouglas_powerview/__init__.py + homeassistant/components/hunterdouglas_powerview/button.py + homeassistant/components/hunterdouglas_powerview/coordinator.py + homeassistant/components/hunterdouglas_powerview/cover.py + homeassistant/components/hunterdouglas_powerview/diagnostics.py + homeassistant/components/hunterdouglas_powerview/entity.py + homeassistant/components/hunterdouglas_powerview/model.py homeassistant/components/hunterdouglas_powerview/scene.py homeassistant/components/hunterdouglas_powerview/sensor.py - homeassistant/components/hunterdouglas_powerview/cover.py - homeassistant/components/hunterdouglas_powerview/entity.py + homeassistant/components/hunterdouglas_powerview/shade_data.py + homeassistant/components/hunterdouglas_powerview/util.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/hvv_departures/__init__.py homeassistant/components/hydrawise/* homeassistant/components/ialarm/alarm_control_panel.py - homeassistant/components/ialarm_xr/alarm_control_panel.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py homeassistant/components/iaqualink/climate.py @@ -575,6 +582,7 @@ omit = homeassistant/components/isy994/sensor.py homeassistant/components/isy994/services.py homeassistant/components/isy994/switch.py + homeassistant/components/isy994/util.py homeassistant/components/itach/remote.py homeassistant/components/itunes/media_player.py homeassistant/components/jellyfin/__init__.py @@ -624,18 +632,16 @@ omit = homeassistant/components/launch_library/const.py homeassistant/components/launch_library/diagnostics.py homeassistant/components/launch_library/sensor.py - homeassistant/components/lcn/binary_sensor.py homeassistant/components/lcn/climate.py homeassistant/components/lcn/helpers.py homeassistant/components/lcn/scene.py - homeassistant/components/lcn/sensor.py homeassistant/components/lcn/services.py homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/media_player.py homeassistant/components/life360/__init__.py homeassistant/components/life360/const.py + homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py - homeassistant/components/life360/helpers.py homeassistant/components/lifx/__init__.py homeassistant/components/lifx/const.py homeassistant/components/lifx/light.py @@ -671,6 +677,7 @@ omit = homeassistant/components/lutron_caseta/light.py homeassistant/components/lutron_caseta/scene.py homeassistant/components/lutron_caseta/switch.py + homeassistant/components/lutron_caseta/util.py homeassistant/components/lw12wifi/light.py homeassistant/components/lyric/__init__.py homeassistant/components/lyric/api.py @@ -785,6 +792,7 @@ omit = homeassistant/components/netgear/router.py homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py + homeassistant/components/netgear/update.py homeassistant/components/netgear_lte/* homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py @@ -957,7 +965,13 @@ omit = homeassistant/components/radarr/sensor.py homeassistant/components/radio_browser/__init__.py homeassistant/components/radio_browser/media_source.py + homeassistant/components/radiotherm/__init__.py + homeassistant/components/radiotherm/entity.py homeassistant/components/radiotherm/climate.py + homeassistant/components/radiotherm/coordinator.py + homeassistant/components/radiotherm/data.py + homeassistant/components/radiotherm/switch.py + homeassistant/components/radiotherm/util.py homeassistant/components/rainbird/* homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py @@ -1049,6 +1063,7 @@ omit = homeassistant/components/shelly/sensor.py homeassistant/components/shelly/utils.py homeassistant/components/sigfox/sensor.py + homeassistant/components/simplepush/__init__.py homeassistant/components/simplepush/notify.py homeassistant/components/simplisafe/__init__.py homeassistant/components/simplisafe/alarm_control_panel.py @@ -1059,7 +1074,14 @@ omit = homeassistant/components/sisyphus/* homeassistant/components/sky_hub/* homeassistant/components/skybeacon/sensor.py - homeassistant/components/skybell/* + homeassistant/components/skybell/__init__.py + homeassistant/components/skybell/binary_sensor.py + homeassistant/components/skybell/camera.py + homeassistant/components/skybell/coordinator.py + homeassistant/components/skybell/entity.py + homeassistant/components/skybell/light.py + homeassistant/components/skybell/sensor.py + homeassistant/components/skybell/switch.py homeassistant/components/slack/__init__.py homeassistant/components/slack/notify.py homeassistant/components/sia/__init__.py @@ -1100,12 +1122,6 @@ omit = homeassistant/components/soma/cover.py homeassistant/components/soma/sensor.py homeassistant/components/soma/utils.py - homeassistant/components/somfy/__init__.py - homeassistant/components/somfy/api.py - homeassistant/components/somfy/climate.py - homeassistant/components/somfy/cover.py - homeassistant/components/somfy/sensor.py - homeassistant/components/somfy/switch.py homeassistant/components/somfy_mylink/__init__.py homeassistant/components/somfy_mylink/cover.py homeassistant/components/sonos/__init__.py @@ -1181,6 +1197,7 @@ omit = homeassistant/components/synology_dsm/binary_sensor.py homeassistant/components/synology_dsm/button.py homeassistant/components/synology_dsm/camera.py + homeassistant/components/synology_dsm/coordinator.py homeassistant/components/synology_dsm/diagnostics.py homeassistant/components/synology_dsm/common.py homeassistant/components/synology_dsm/entity.py @@ -1284,9 +1301,6 @@ omit = homeassistant/components/tradfri/light.py homeassistant/components/tradfri/sensor.py homeassistant/components/tradfri/switch.py - homeassistant/components/trafikverket_ferry/__init__.py - homeassistant/components/trafikverket_ferry/coordinator.py - homeassistant/components/trafikverket_ferry/sensor.py homeassistant/components/trafikverket_train/__init__.py homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/__init__.py @@ -1345,6 +1359,7 @@ omit = homeassistant/components/vasttrafik/sensor.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py + homeassistant/components/velbus/button.py homeassistant/components/velbus/climate.py homeassistant/components/velbus/const.py homeassistant/components/velbus/cover.py @@ -1476,9 +1491,11 @@ omit = homeassistant/components/yolink/__init__.py homeassistant/components/yolink/api.py homeassistant/components/yolink/binary_sensor.py + homeassistant/components/yolink/climate.py homeassistant/components/yolink/const.py homeassistant/components/yolink/coordinator.py homeassistant/components/yolink/entity.py + homeassistant/components/yolink/lock.py homeassistant/components/yolink/sensor.py homeassistant/components/yolink/siren.py homeassistant/components/yolink/switch.py @@ -1500,7 +1517,6 @@ omit = homeassistant/components/zha/core/gateway.py homeassistant/components/zha/core/helpers.py homeassistant/components/zha/core/registries.py - homeassistant/components/zha/core/typing.py homeassistant/components/zha/entity.py homeassistant/components/zha/light.py homeassistant/components/zha/sensor.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 48c0782dafa..6eea7cea953 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -104,7 +104,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -135,7 +135,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.03.1 + uses: home-assistant/builder@2022.06.2 with: args: | $BUILD_ARGS \ @@ -171,6 +171,7 @@ jobs: - raspberrypi4 - raspberrypi4-64 - tinker + - yellow steps: - name: Checkout the repository uses: actions/checkout@v3.0.2 @@ -200,7 +201,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.03.1 + uses: home-assistant/builder@2022.06.2 with: args: | $BUILD_ARGS \ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fb659cf21d2..bf690740c6d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,14 +20,15 @@ on: type: boolean env: - CACHE_VERSION: 9 - PIP_CACHE_VERSION: 3 - HA_SHORT_VERSION: 2022.6 + CACHE_VERSION: 10 + PIP_CACHE_VERSION: 4 + HA_SHORT_VERSION: 2022.7 DEFAULT_PYTHON: 3.9 PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 + HASS_CI: 1 concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -155,7 +156,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Generate partial Python venv restore key @@ -172,7 +173,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: >- @@ -189,7 +190,7 @@ jobs: # ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}- - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: ${{ env.PIP_CACHE }} key: >- @@ -212,7 +213,7 @@ jobs: hashFiles('.pre-commit-config.yaml') }}" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: >- @@ -235,13 +236,13 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -253,7 +254,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -285,13 +286,13 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -303,7 +304,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -336,13 +337,13 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -354,7 +355,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -378,13 +379,13 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -396,7 +397,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -502,7 +503,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -525,13 +526,13 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -573,7 +574,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: >- @@ -590,7 +591,7 @@ jobs: # ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}- - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: ${{ env.PIP_CACHE }} key: >- @@ -629,7 +630,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -671,7 +672,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -689,7 +690,7 @@ jobs: run: | . venv/bin/activate python --version - mypy homeassistant + mypy homeassistant pylint - name: Run mypy (partially) if: needs.changes.outputs.test_full_suite == 'false' shell: bash @@ -715,7 +716,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -758,7 +759,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -795,14 +796,14 @@ jobs: --dist=loadfile \ --test-group-count ${{ needs.changes.outputs.test_group_count }} \ --test-group=${{ matrix.group }} \ - --cov homeassistant \ + --cov="homeassistant" \ --cov-report=xml \ -o console_output_style=count \ -p no:sugar \ tests - name: Run pytest (partially) if: needs.changes.outputs.test_full_suite == 'false' - timeout-minutes: 10 + timeout-minutes: 20 shell: bash run: | . venv/bin/activate @@ -818,7 +819,7 @@ jobs: --timeout=9 \ --durations=10 \ -n auto \ - --cov homeassistant.components.${{ matrix.group }} \ + --cov="homeassistant.components.${{ matrix.group }}" \ --cov-report=xml \ --cov-report=term-missing \ -o console_output_style=count \ diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index 716ae870b50..0d3ddc4ca18 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6019e533530..95f1d8e437e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -47,6 +47,13 @@ jobs: # execinfo-dev when building wheels. The setuptools build setup does not have an option for # adding a single LDFLAG so copy all relevant linux flags here (as of 1.43.0) echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc -lexecinfo" + + # Fix out of memory issues with rust + echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" + + # OpenCV headless installation + echo "CI_BUILD=1" + echo "ENABLE_HEADLESS=1" ) > .env_file - name: Upload env_file @@ -62,7 +69,7 @@ jobs: path: ./requirements_diff.txt core: - name: Build wheels with ${{ matrix.tag }} (${{ matrix.arch }}) for core + name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for core if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest @@ -70,8 +77,6 @@ jobs: fail-fast: false matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} - tag: - - "3.9-alpine3.14" steps: - name: Checkout the repository uses: actions/checkout@v3.0.2 @@ -87,23 +92,21 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2022.01.2 + uses: home-assistant/wheels@2022.06.7 with: - tag: ${{ matrix.tag }} + abi: cp310 + tag: musllinux_1_2 arch: ${{ matrix.arch }} - wheels-host: wheels.hass.io wheels-key: ${{ secrets.WHEELS_KEY }} - wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;cargo" - pip: "Cython;numpy==1.21.6" + apk: "libffi-dev;openssl-dev;yaml-dev" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" integrations: - name: Build wheels with ${{ matrix.tag }} (${{ matrix.arch }}) for integrations + name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for integrations if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest @@ -111,8 +114,6 @@ jobs: fail-fast: false matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} - tag: - - "3.9-alpine3.14" steps: - name: Checkout the repository uses: actions/checkout@v3.0.2 @@ -132,35 +133,41 @@ jobs: requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do sed -i "s|# pybluez|pybluez|g" ${requirement_file} - sed -i "s|# bluepy|bluepy|g" ${requirement_file} sed -i "s|# beacontools|beacontools|g" ${requirement_file} sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} sed -i "s|# evdev|evdev|g" ${requirement_file} - sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} sed -i "s|# pycups|pycups|g" ${requirement_file} sed -i "s|# homekit|homekit|g" ${requirement_file} sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} - sed -i "s|# decora|decora|g" ${requirement_file} - sed -i "s|# avion|avion|g" ${requirement_file} - sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} - sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} + sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file} done + - name: Adjust build env + run: | + if [ "${{ matrix.arch }}" = "i386" ]; then + echo "NPY_DISABLE_SVML=1" >> .env_file + fi + + ( + # cmake > 3.22.2 have issue on arm + # Tested until 3.22.5 + echo "cmake==3.22.2" + ) >> homeassistant/package_constraints.txt + - name: Build wheels - uses: home-assistant/wheels@2022.01.2 + uses: home-assistant/wheels@2022.06.7 with: - tag: ${{ matrix.tag }} + abi: cp310 + tag: musllinux_1_2 arch: ${{ matrix.arch }} - wheels-host: wheels.hass.io wheels-key: ${{ secrets.WHEELS_KEY }} - wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;cargo" - pip: "Cython;numpy;scikit-build" - skip-binary: aiohttp,grpcio + apk: "libexecinfo-dev;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" + skip-binary: aiohttp;grpcio + legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txt" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 539308c08f1..18b34a222aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.32.1 + rev: v2.34.0 hooks: - id: pyupgrade args: [--py39-plus] @@ -93,7 +93,7 @@ repos: language: script types: [python] require_serial: true - files: ^homeassistant/.+\.py$ + files: ^(homeassistant|pylint)/.+\.py$ - id: pylint name: pylint entry: script/run-in-env.sh pylint -j 0 @@ -106,7 +106,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/manifest\.json|setup\.cfg|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$ + files: ^(homeassistant/.+/manifest\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$ - id: hassfest name: hassfest entry: script/run-in-env.sh python3 -m script.hassfest @@ -120,7 +120,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|setup\.cfg)$ + files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$ - id: hassfest-mypy-config name: hassfest-mypy-config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config diff --git a/.strict-typing b/.strict-typing index e07d8b9cfc8..1832a83641a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -83,9 +83,11 @@ homeassistant.components.dunehd.* homeassistant.components.efergy.* homeassistant.components.elgato.* homeassistant.components.elkm1.* +homeassistant.components.emulated_hue.* homeassistant.components.esphome.* homeassistant.components.energy.* homeassistant.components.evil_genius_labs.* +homeassistant.components.fan.* homeassistant.components.fastdotcom.* homeassistant.components.filesize.* homeassistant.components.fitbit.* @@ -113,6 +115,7 @@ homeassistant.components.homekit.aidmanager homeassistant.components.homekit.config_flow homeassistant.components.homekit.diagnostics homeassistant.components.homekit.logbook +homeassistant.components.homekit.type_locks homeassistant.components.homekit.type_triggers homeassistant.components.homekit.util homeassistant.components.homekit_controller @@ -127,7 +130,6 @@ homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* -homeassistant.components.ialarm_xr.* homeassistant.components.image_processing.* homeassistant.components.input_button.* homeassistant.components.input_select.* @@ -196,6 +198,7 @@ homeassistant.components.rtsp_to_webrtc.* homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.select.* +homeassistant.components.sensibo.* homeassistant.components.sensor.* homeassistant.components.senseme.* homeassistant.components.senz.* @@ -224,6 +227,7 @@ homeassistant.components.tplink.* homeassistant.components.tolo.* homeassistant.components.tractive.* homeassistant.components.tradfri.* +homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.tts.* diff --git a/CODEOWNERS b/CODEOWNERS index 1b0d0ae4d3c..5f5588c0b91 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,6 +6,7 @@ # Home Assistant Core setup.cfg @home-assistant/core +pyproject.toml @home-assistant/core /homeassistant/*.py @home-assistant/core /homeassistant/helpers/ @home-assistant/core /homeassistant/util/ @home-assistant/core @@ -128,8 +129,8 @@ build.json @home-assistant/supervisor /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @bbx-jp -/tests/components/blebox/ @bbx-a @bbx-jp +/homeassistant/components/blebox/ @bbx-a @bbx-jp @riokuu +/tests/components/blebox/ @bbx-a @bbx-jp @riokuu /homeassistant/components/blink/ @fronzbot /tests/components/blink/ @fronzbot /homeassistant/components/blueprint/ @home-assistant/core @@ -272,6 +273,7 @@ build.json @home-assistant/supervisor /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eight_sleep/ @mezz64 @raman325 +/tests/components/eight_sleep/ @mezz64 @raman325 /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco @@ -283,6 +285,8 @@ build.json @home-assistant/supervisor /homeassistant/components/emoncms/ @borpin /homeassistant/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco +/homeassistant/components/emulated_hue/ @bdraco +/tests/components/emulated_hue/ @bdraco /homeassistant/components/emulated_kasa/ @kbickar /tests/components/emulated_kasa/ @kbickar /homeassistant/components/energy/ @home-assistant/core @@ -327,7 +331,6 @@ build.json @home-assistant/supervisor /tests/components/firmata/ @DaAwesomeP /homeassistant/components/fivem/ @Sander0542 /tests/components/fivem/ @Sander0542 -/homeassistant/components/fixer/ @fabaff /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus /homeassistant/components/flick_electric/ @ZephireNZ @@ -363,6 +366,7 @@ build.json @home-assistant/supervisor /tests/components/fronius/ @nielstron @farmio /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend +/homeassistant/components/frontier_silicon/ @wlcrs /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gdacs/ @exxamalte @@ -445,6 +449,8 @@ build.json @home-assistant/supervisor /tests/components/home_plus_control/ @chemaaa /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core +/homeassistant/components/homeassistant_yellow/ @home-assistant/core +/tests/components/homeassistant_yellow/ @home-assistant/core /homeassistant/components/homekit/ @bdraco /tests/components/homekit/ @bdraco /homeassistant/components/homekit_controller/ @Jc2k @bdraco @@ -465,8 +471,8 @@ build.json @home-assistant/supervisor /tests/components/huisbaasje/ @dennisschroer /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka -/homeassistant/components/hunterdouglas_powerview/ @bdraco @trullock -/tests/components/hunterdouglas_powerview/ @bdraco @trullock +/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @ptcryan @@ -474,8 +480,6 @@ build.json @home-assistant/supervisor /tests/components/hyperion/ @dermotduffy /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK -/homeassistant/components/ialarm_xr/ @bigmoby -/tests/components/ialarm_xr/ @bigmoby /homeassistant/components/iammeter/ @lewei50 /homeassistant/components/iaqualink/ @flz /tests/components/iaqualink/ @flz @@ -568,6 +572,7 @@ build.json @home-assistant/supervisor /tests/components/lcn/ @alengwenus /homeassistant/components/lg_netcast/ @Drafteed /homeassistant/components/life360/ @pnbruckner +/tests/components/life360/ @pnbruckner /homeassistant/components/lifx/ @Djelibeybi /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core @@ -824,7 +829,8 @@ build.json @home-assistant/supervisor /tests/components/rachio/ @bdraco /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck -/homeassistant/components/radiotherm/ @vinnyfuria +/homeassistant/components/radiotherm/ @bdraco @vinnyfuria +/tests/components/radiotherm/ @bdraco @vinnyfuria /homeassistant/components/rainbird/ @konikvranik /homeassistant/components/raincloud/ @vanstinator /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin @@ -924,6 +930,8 @@ build.json @home-assistant/supervisor /tests/components/sighthound/ @robmarkcole /homeassistant/components/signal_messenger/ @bbernhard /tests/components/signal_messenger/ @bbernhard +/homeassistant/components/simplepush/ @engrbm87 +/tests/components/simplepush/ @engrbm87 /homeassistant/components/simplisafe/ @bachya /tests/components/simplisafe/ @bachya /homeassistant/components/sinch/ @bendikrb @@ -931,6 +939,8 @@ build.json @home-assistant/supervisor /tests/components/siren/ @home-assistant/core @raman325 /homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sky_hub/ @rogerselwyn +/homeassistant/components/skybell/ @tkdrob +/tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @bachya @tkdrob /tests/components/slack/ @bachya @tkdrob /homeassistant/components/sleepiq/ @mfugate1 @kbickar @@ -961,8 +971,6 @@ build.json @home-assistant/supervisor /tests/components/solax/ @squishykid /homeassistant/components/soma/ @ratsept @sebfortier2288 /tests/components/soma/ @ratsept @sebfortier2288 -/homeassistant/components/somfy/ @tetienne -/tests/components/somfy/ @tetienne /homeassistant/components/sonarr/ @ctalkington /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn @@ -1061,12 +1069,12 @@ build.json @home-assistant/supervisor /tests/components/todoist/ @boralyl /homeassistant/components/tolo/ @MatthiasLohr /tests/components/tolo/ @MatthiasLohr -/homeassistant/components/tomorrowio/ @raman325 -/tests/components/tomorrowio/ @raman325 +/homeassistant/components/tomorrowio/ @raman325 @lymanepp +/tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco -/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco +/homeassistant/components/tplink/ @rytilahti @thegardenmonkey +/tests/components/tplink/ @rytilahti @thegardenmonkey /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus /homeassistant/components/trace/ @home-assistant/core @@ -1157,8 +1165,8 @@ build.json @home-assistant/supervisor /tests/components/watttime/ @bachya /homeassistant/components/waze_travel_time/ @eifinger /tests/components/waze_travel_time/ @eifinger -/homeassistant/components/weather/ @fabaff -/tests/components/weather/ @fabaff +/homeassistant/components/weather/ @home-assistant/core +/tests/components/weather/ @home-assistant/core /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webostv/ @bendavid @thecode diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index f9b1ea79314..09828047616 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -123,7 +123,7 @@ enforcement ladder][mozilla]. ## Adoption -This Code of Conduct was first adopted January 21st, 2017 and announced in +This Code of Conduct was first adopted on January 21st, 2017, and announced in [this][coc-blog] blog post and has been updated on May 25th, 2020 to version 2.0 of the [Contributor Covenant][homepage] as announced in [this][coc2-blog] blog post. diff --git a/Dockerfile b/Dockerfile index 1d6ce675e74..13552d55a3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,21 +25,6 @@ RUN \ -e ./homeassistant --use-deprecated=legacy-resolver \ && python3 -m compileall homeassistant/homeassistant -# Fix Bug with Alpine 3.14 and sqlite 3.35 -# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524 -ARG BUILD_ARCH -RUN \ - if [ "${BUILD_ARCH}" = "amd64" ]; then \ - export APK_ARCH=x86_64; \ - elif [ "${BUILD_ARCH}" = "i386" ]; then \ - export APK_ARCH=x86; \ - else \ - export APK_ARCH=${BUILD_ARCH}; \ - fi \ - && curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \ - && apk add --no-cache sqlite-libs-3.34.1-r0.apk \ - && rm -f sqlite-libs-3.34.1-r0.apk - # Home Assistant S6-Overlay COPY rootfs / diff --git a/Dockerfile.dev b/Dockerfile.dev index 322c63f53dd..0559ebb43cd 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -18,6 +18,7 @@ RUN \ libavfilter-dev \ libpcap-dev \ libturbojpeg0 \ + libyaml-dev \ libxml2 \ git \ cmake \ diff --git a/build.yaml b/build.yaml index 196277184a3..7bd4abcfc20 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.05.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.05.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.05.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.05.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.05.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.06.2 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.06.2 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.06.2 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.06.2 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.06.2 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index b3f57656dd7..12511a7f4a5 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -20,6 +20,7 @@ from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config EVENT_USER_ADDED = "user_added" +EVENT_USER_UPDATED = "user_updated" EVENT_USER_REMOVED = "user_removed" _MfaModuleDict = dict[str, MultiFactorAuthModule] @@ -103,7 +104,7 @@ class AuthManagerFlowManager(data_entry_flow.FlowManager): """Return a user as result of login flow.""" flow = cast(LoginFlow, flow) - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result # we got final result @@ -338,6 +339,8 @@ class AuthManager: else: await self.async_deactivate_user(user) + self.hass.bus.async_fire(EVENT_USER_UPDATED, {"user_id": user.id}) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 63389059051..6feb4b26759 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -272,7 +272,7 @@ class LoginFlow(data_entry_flow.FlowHandler): if not errors: return await self.async_finish(self.credential) - description_placeholders: dict[str, str | None] = { + description_placeholders: dict[str, str] = { "mfa_module_name": auth_module.name, "mfa_module_id": auth_module.id, } diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 986171cbee7..81d0fa72134 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import logging import logging.handlers import os +import platform import sys import threading from time import monotonic @@ -398,7 +399,7 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded - if "HASSIO" in os.environ: + if "SUPERVISOR" in os.environ: domains.add("hassio") return domains @@ -540,11 +541,22 @@ async def _async_set_up_integrations( stage_2_domains = domains_to_setup - logging_domains - debuggers - stage_1_domains + def _cache_uname_processor() -> None: + """Cache the result of platform.uname().processor in the executor. + + Multiple modules call this function at startup which + executes a blocking subprocess call. This is a problem for the + asyncio event loop. By primeing the cache of uname we can + avoid the blocking call in the event loop. + """ + platform.uname().processor # pylint: disable=expression-not-assigned + # Load the registries await asyncio.gather( device_registry.async_load(hass), entity_registry.async_load(hass), area_registry.async_load(hass), + hass.async_add_executor_job(_cache_uname_processor), ) # Start setup diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index 715bc53e2b2..4c3d44bebbe 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Abode Security System component.""" from __future__ import annotations +from collections.abc import Mapping from http import HTTPStatus from typing import Any, cast @@ -149,9 +150,9 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_abode_mfa_login() - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauthorization request from Abode.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index f998e21510d..1bb9d41f461 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -11,9 +11,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + ColorMode, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -101,11 +99,27 @@ class AbodeLight(AbodeDevice, LightEntity): _hs = self._device.color return _hs + @property + def color_mode(self) -> str | None: + """Return the color mode of the light.""" + if self._device.is_dimmable and self._device.is_color_capable: + if self.hs_color is not None: + return ColorMode.HS + return ColorMode.COLOR_TEMP + if self._device.is_dimmable: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + @property + def supported_color_modes(self) -> set[str] | None: + """Flag supported color modes.""" + if self._device.is_dimmable and self._device.is_color_capable: + return {ColorMode.COLOR_TEMP, ColorMode.HS} + if self._device.is_dimmable: + return {ColorMode.BRIGHTNESS} + return {ColorMode.ONOFF} + @property def supported_features(self) -> int: """Flag supported features.""" - if self._device.is_dimmable and self._device.is_color_capable: - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP - if self._device.is_dimmable: - return SUPPORT_BRIGHTNESS return 0 diff --git a/homeassistant/components/abode/translations/sv.json b/homeassistant/components/abode/translations/sv.json index 9faf392be51..ef61917ad43 100644 --- a/homeassistant/components/abode/translations/sv.json +++ b/homeassistant/components/abode/translations/sv.json @@ -4,6 +4,11 @@ "single_instance_allowed": "Endast en enda konfiguration av Abode \u00e4r till\u00e5ten." }, "step": { + "reauth_confirm": { + "data": { + "username": "E-postadress" + } + }, "user": { "data": { "password": "L\u00f6senord", diff --git a/homeassistant/components/accuweather/translations/es.json b/homeassistant/components/accuweather/translations/es.json index 0c3d5560d67..ef91348a727 100644 --- a/homeassistant/components/accuweather/translations/es.json +++ b/homeassistant/components/accuweather/translations/es.json @@ -3,6 +3,9 @@ "abort": { "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, + "create_entry": { + "default": "Algunos sensores no est\u00e1n habilitados de forma predeterminada. Puede habilitarlos en el registro de la entidad despu\u00e9s de la configuraci\u00f3n de la integraci\u00f3n.\n El pron\u00f3stico del tiempo no est\u00e1 habilitado de forma predeterminada. Puedes habilitarlo en las opciones de integraci\u00f3n." + }, "error": { "cannot_connect": "No se pudo conectar", "invalid_api_key": "Clave API no v\u00e1lida", diff --git a/homeassistant/components/accuweather/translations/he.json b/homeassistant/components/accuweather/translations/he.json index 0f054ff11fe..f4b95268a25 100644 --- a/homeassistant/components/accuweather/translations/he.json +++ b/homeassistant/components/accuweather/translations/he.json @@ -3,6 +3,9 @@ "abort": { "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, + "create_entry": { + "default": "\u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05de\u05e1\u05d5\u05d9\u05de\u05d9\u05dd \u05d0\u05d9\u05e0\u05dd \u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d9\u05db\u05d5\u05dc\u05ea\u05da \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d5\u05ea\u05dd \u05d1\u05e8\u05d9\u05e9\u05d5\u05dd \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05dc\u05d0\u05d7\u05e8 \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.\n \u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8 \u05d0\u05d9\u05e0\u05d4 \u05de\u05d5\u05e4\u05e2\u05dc\u05ea \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d9\u05db\u05d5\u05dc\u05ea\u05da \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05ea \u05d6\u05d4 \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1." + }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", diff --git a/homeassistant/components/accuweather/translations/sv.json b/homeassistant/components/accuweather/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/accuweather/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 536f66a3cb9..ae1824aef4a 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -6,19 +6,26 @@ from typing import Any, cast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, Forecast, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_MILLIMETERS, + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -66,19 +73,25 @@ class AccuWeatherEntity( ) -> None: """Initialize.""" super().__init__(coordinator) - self._unit_system = API_METRIC if coordinator.is_metric else API_IMPERIAL - wind_speed_unit = self.coordinator.data["Wind"]["Speed"][self._unit_system][ - "Unit" - ] - if wind_speed_unit == "mi/h": - self._attr_wind_speed_unit = SPEED_MILES_PER_HOUR + # Coordinator data is used also for sensors which don't have units automatically + # converted, hence the weather entity's native units follow the configured unit + # system + if coordinator.is_metric: + self._attr_native_precipitation_unit = LENGTH_MILLIMETERS + self._attr_native_pressure_unit = PRESSURE_HPA + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_native_visibility_unit = LENGTH_KILOMETERS + self._attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + self._unit_system = API_METRIC else: - self._attr_wind_speed_unit = wind_speed_unit + self._unit_system = API_IMPERIAL + self._attr_native_precipitation_unit = LENGTH_INCHES + self._attr_native_pressure_unit = PRESSURE_INHG + self._attr_native_temperature_unit = TEMP_FAHRENHEIT + self._attr_native_visibility_unit = LENGTH_MILES + self._attr_native_wind_speed_unit = SPEED_MILES_PER_HOUR self._attr_name = name self._attr_unique_id = coordinator.location_key - self._attr_temperature_unit = ( - TEMP_CELSIUS if coordinator.is_metric else TEMP_FAHRENHEIT - ) self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -106,14 +119,14 @@ class AccuWeatherEntity( return None @property - def temperature(self) -> float: + def native_temperature(self) -> float: """Return the temperature.""" return cast( float, self.coordinator.data["Temperature"][self._unit_system]["Value"] ) @property - def pressure(self) -> float: + def native_pressure(self) -> float: """Return the pressure.""" return cast( float, self.coordinator.data["Pressure"][self._unit_system]["Value"] @@ -125,7 +138,7 @@ class AccuWeatherEntity( return cast(int, self.coordinator.data["RelativeHumidity"]) @property - def wind_speed(self) -> float: + def native_wind_speed(self) -> float: """Return the wind speed.""" return cast( float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] @@ -137,7 +150,7 @@ class AccuWeatherEntity( return cast(int, self.coordinator.data["Wind"]["Direction"]["Degrees"]) @property - def visibility(self) -> float: + def native_visibility(self) -> float: """Return the visibility.""" return cast( float, self.coordinator.data["Visibility"][self._unit_system]["Value"] @@ -162,9 +175,9 @@ class AccuWeatherEntity( return [ { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), - ATTR_FORECAST_TEMP: item["TemperatureMax"]["Value"], - ATTR_FORECAST_TEMP_LOW: item["TemperatureMin"]["Value"], - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(item), + ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"]["Value"], + ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"]["Value"], + ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation(item), ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( mean( [ @@ -173,7 +186,7 @@ class AccuWeatherEntity( ] ) ), - ATTR_FORECAST_WIND_SPEED: item["WindDay"]["Speed"]["Value"], + ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"]["Speed"]["Value"], ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], ATTR_FORECAST_CONDITION: [ k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 2fbd2de6c42..887e26cd7fc 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -1,6 +1,8 @@ """Support for Acmeda Roller Blinds.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, CoverEntity, @@ -45,7 +47,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): """Representation of a Acmeda cover device.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of the roller blind. None is unknown, 0 is closed, 100 is fully open. @@ -56,7 +58,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): return position @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt of the roller blind. None is unknown, 0 is closed, 100 is fully open. @@ -67,7 +69,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): return position @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = 0 if self.current_cover_position is not None: @@ -88,35 +90,35 @@ class AcmedaCover(AcmedaBase, CoverEntity): return supported_features @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.roller.closed_percent == 100 - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the roller.""" await self.roller.move_down() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the roller.""" await self.roller.move_up() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the roller.""" await self.roller.move_stop() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" await self.roller.move_to(100 - kwargs[ATTR_POSITION]) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the roller.""" await self.roller.move_down() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the roller.""" await self.roller.move_up() - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the roller.""" await self.roller.move_stop() diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 1f2645e227c..2a244a5fe80 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -115,14 +115,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload AdGuard Home config entry.""" - hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) - hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) - hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) - hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) + hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) del hass.data[DOMAIN] return unload_ok diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index c1f057b588e..a2fb1888cd3 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -1,6 +1,8 @@ """Support for ADS covers.""" from __future__ import annotations +from typing import Any + import pyads import voluptuous as vol @@ -122,7 +124,7 @@ class AdsCover(AdsEntity, CoverEntity): if ads_var_pos_set is not None: self._attr_supported_features |= CoverEntityFeature.SET_POSITION - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register device notification.""" if self._ads_var is not None: await self.async_initialize_device(self._ads_var, pyads.PLCTYPE_BOOL) @@ -133,7 +135,7 @@ class AdsCover(AdsEntity, CoverEntity): ) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._ads_var is not None: return self._state_dict[STATE_KEY_STATE] @@ -142,16 +144,16 @@ class AdsCover(AdsEntity, CoverEntity): return None @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return current position of cover.""" return self._state_dict[STATE_KEY_POSITION] - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" if self._ads_var_stop: self._ads_hub.write_by_name(self._ads_var_stop, True, pyads.PLCTYPE_BOOL) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Set cover position.""" position = kwargs[ATTR_POSITION] if self._ads_var_pos_set is not None: @@ -159,14 +161,14 @@ class AdsCover(AdsEntity, CoverEntity): self._ads_var_pos_set, position, pyads.PLCTYPE_BYTE ) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._ads_var_open is not None: self._ads_hub.write_by_name(self._ads_var_open, True, pyads.PLCTYPE_BOOL) elif self._ads_var_pos_set is not None: self.set_cover_position(position=100) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" if self._ads_var_close is not None: self._ads_hub.write_by_name(self._ads_var_close, True, pyads.PLCTYPE_BOOL) diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index 847ca41c42c..36ae2c7fff0 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -1,4 +1,6 @@ """Cover platform for Advantage Air integration.""" +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -56,18 +58,18 @@ class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): ) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if vent is fully closed.""" return self._zone["state"] == ADVANTAGE_AIR_STATE_CLOSE @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return vents current position as a percentage.""" if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: return self._zone["value"] return 0 - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Fully open zone vent.""" await self.async_change( { @@ -79,7 +81,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): } ) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Fully close zone vent.""" await self.async_change( { @@ -89,7 +91,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): } ) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Change vent position.""" position = round(kwargs[ATTR_POSITION] / 5) * 5 if position == 0: diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 6c97ca98cb8..1188df5b94f 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,4 +1,6 @@ """Config flow for AEMET OpenData.""" +from __future__ import annotations + from aemet_opendata import AEMET import voluptuous as vol @@ -50,7 +52,9 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 4be90011f5a..645c1ad0ea2 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -17,14 +17,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_RAINY, ATTR_CONDITION_SNOWY, ATTR_CONDITION_SUNNY, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, ) from homeassistant.const import ( DEGREE, @@ -45,8 +37,16 @@ ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" ATTR_API_CONDITION = "condition" +ATTR_API_FORECAST_CONDITION = "condition" ATTR_API_FORECAST_DAILY = "forecast-daily" ATTR_API_FORECAST_HOURLY = "forecast-hourly" +ATTR_API_FORECAST_PRECIPITATION = "precipitation" +ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" +ATTR_API_FORECAST_TEMP = "temperature" +ATTR_API_FORECAST_TEMP_LOW = "templow" +ATTR_API_FORECAST_TIME = "datetime" +ATTR_API_FORECAST_WIND_BEARING = "wind_bearing" +ATTR_API_FORECAST_WIND_SPEED = "wind_speed" ATTR_API_HUMIDITY = "humidity" ATTR_API_PRESSURE = "pressure" ATTR_API_RAIN = "rain" @@ -158,14 +158,14 @@ CONDITIONS_MAP = { } FORECAST_MONITORED_CONDITIONS = [ - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ] MONITORED_CONDITIONS = [ ATTR_API_CONDITION, @@ -202,43 +202,43 @@ FORECAST_MODE_ATTR_API = { FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key=ATTR_FORECAST_CONDITION, + key=ATTR_API_FORECAST_CONDITION, name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION, + key=ATTR_API_FORECAST_PRECIPITATION, name="Precipitation", native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION_PROBABILITY, + key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, name="Precipitation probability", native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP, + key=ATTR_API_FORECAST_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP_LOW, + key=ATTR_API_FORECAST_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TIME, + key=ATTR_API_FORECAST_TIME, name="Time", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( - key=ATTR_FORECAST_WIND_BEARING, + key=ATTR_API_FORECAST_WIND_BEARING, name="Wind bearing", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( - key=ATTR_FORECAST_WIND_SPEED, + key=ATTR_API_FORECAST_WIND_SPEED, name="Wind speed", native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, ), diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f98e3fff49e..e34583148e1 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( - ATTR_FORECAST_TIME, + ATTR_API_FORECAST_TIME, ATTRIBUTION, DOMAIN, ENTRY_NAME, @@ -45,17 +45,13 @@ async def async_setup_entry( entities.extend( [ AemetForecastSensor( - name_prefix, - unique_id_prefix, + f"{domain_data[ENTRY_NAME]} {mode} Forecast", + f"{unique_id}-forecast-{mode}", weather_coordinator, mode, description, ) for mode in FORECAST_MODES - if ( - (name_prefix := f"{domain_data[ENTRY_NAME]} {mode} Forecast") - and (unique_id_prefix := f"{unique_id}-forecast-{mode}") - ) for description in FORECAST_SENSOR_TYPES if description.key in FORECAST_MONITORED_CONDITIONS ] @@ -89,14 +85,14 @@ class AemetSensor(AbstractAemetSensor): def __init__( self, name, - unique_id, + unique_id_prefix, weather_coordinator: WeatherUpdateCoordinator, description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id}-{description.key}", + unique_id=f"{unique_id_prefix}-{description.key}", coordinator=weather_coordinator, description=description, ) @@ -113,7 +109,7 @@ class AemetForecastSensor(AbstractAemetSensor): def __init__( self, name, - unique_id, + unique_id_prefix, weather_coordinator: WeatherUpdateCoordinator, forecast_mode, description: SensorEntityDescription, @@ -121,7 +117,7 @@ class AemetForecastSensor(AbstractAemetSensor): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id}-{description.key}", + unique_id=f"{unique_id_prefix}-{description.key}", coordinator=weather_coordinator, description=description, ) @@ -139,6 +135,6 @@ class AemetForecastSensor(AbstractAemetSensor): ) if forecasts: forecast = forecasts[0].get(self.entity_description.key) - if self.entity_description.key == ATTR_FORECAST_TIME: + if self.entity_description.key == ATTR_API_FORECAST_TIME: forecast = dt_util.parse_datetime(forecast) return forecast diff --git a/homeassistant/components/aemet/translations/bg.json b/homeassistant/components/aemet/translations/bg.json index 62d0a34441a..4823a30fd97 100644 --- a/homeassistant/components/aemet/translations/bg.json +++ b/homeassistant/components/aemet/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" }, diff --git a/homeassistant/components/aemet/translations/sv.json b/homeassistant/components/aemet/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/aemet/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index d05442b621e..a7ff3630e78 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,13 +1,36 @@ """Support for the AEMET OpenData service.""" -from homeassistant.components.weather import WeatherEntity +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + WeatherEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_API_CONDITION, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, @@ -19,10 +42,32 @@ from .const import ( ENTRY_WEATHER_COORDINATOR, FORECAST_MODE_ATTR_API, FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, FORECAST_MODES, ) from .weather_update_coordinator import WeatherUpdateCoordinator +FORECAST_MAP = { + FORECAST_MODE_DAILY: { + ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, + ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, + FORECAST_MODE_HOURLY: { + ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, + ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, +} + async def async_setup_entry( hass: HomeAssistant, @@ -47,9 +92,10 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION - _attr_temperature_unit = TEMP_CELSIUS - _attr_pressure_unit = PRESSURE_HPA - _attr_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR def __init__( self, @@ -75,7 +121,12 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): @property def forecast(self): """Return the forecast array.""" - return self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]] + forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]] + forecast_map = FORECAST_MAP[self._forecast_mode] + return [ + {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} + for forecast in forecasts + ] @property def humidity(self): @@ -83,12 +134,12 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_HUMIDITY] @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.coordinator.data[ATTR_API_PRESSURE] @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data[ATTR_API_TEMPERATURE] @@ -98,6 +149,6 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_WIND_BEARING] @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self.coordinator.data[ATTR_API_WIND_SPEED] diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index c86465ea8f1..1c64206891c 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -42,23 +42,21 @@ from aemet_opendata.helpers import ( ) import async_timeout -from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, -) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ( ATTR_API_CONDITION, + ATTR_API_FORECAST_CONDITION, ATTR_API_FORECAST_DAILY, ATTR_API_FORECAST_HOURLY, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_RAIN, @@ -402,15 +400,15 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return None return { - ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( + ATTR_API_FORECAST_CONDITION: condition, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( day ), - ATTR_FORECAST_TEMP: self._get_temperature_day(day), - ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), - ATTR_FORECAST_TIME: dt_util.as_utc(date).isoformat(), - ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), - ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), + ATTR_API_FORECAST_TEMP: self._get_temperature_day(day), + ATTR_API_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), + ATTR_API_FORECAST_TIME: dt_util.as_utc(date).isoformat(), + ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), + ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), } def _convert_forecast_hour(self, date, day, hour): @@ -420,15 +418,15 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): forecast_dt = date.replace(hour=hour, minute=0, second=0) return { - ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( + ATTR_API_FORECAST_CONDITION: condition, + ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( day, hour ), - ATTR_FORECAST_TEMP: self._get_temperature(day, hour), - ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), - ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), - ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), + ATTR_API_FORECAST_TEMP: self._get_temperature(day, hour), + ATTR_API_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), + ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), + ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), } def _calc_precipitation(self, day, hour): diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 8978be97c1d..632b2e29d57 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Agent DVR Alarm Control Panels.""" +from __future__ import annotations + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -58,7 +60,7 @@ class AgentBaseStation(AlarmControlPanelEntity): sw_version=client.version, ) - async def async_update(self): + async def async_update(self) -> None: """Update the state of the device.""" await self._client.update() self._attr_available = self._client.is_available @@ -76,24 +78,24 @@ class AgentBaseStation(AlarmControlPanelEntity): else: self._attr_state = STATE_ALARM_DISARMED - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._client.disarm() self._attr_state = STATE_ALARM_DISARMED - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_AWAY_MODE_NAME) self._attr_state = STATE_ALARM_ARMED_AWAY - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_HOME_MODE_NAME) self._attr_state = STATE_ALARM_ARMED_HOME - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_NIGHT_MODE_NAME) diff --git a/homeassistant/components/agent_dvr/translations/bg.json b/homeassistant/components/agent_dvr/translations/bg.json index 527adb67bf7..cc5f200ef95 100644 --- a/homeassistant/components/agent_dvr/translations/bg.json +++ b/homeassistant/components/agent_dvr/translations/bg.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/airly/translations/sv.json b/homeassistant/components/airly/translations/sv.json index d182115230b..ac976224924 100644 --- a/homeassistant/components/airly/translations/sv.json +++ b/homeassistant/components/airly/translations/sv.json @@ -4,6 +4,7 @@ "already_configured": "Airly-integrationen f\u00f6r dessa koordinater \u00e4r redan konfigurerad." }, "error": { + "invalid_api_key": "Ogiltig API-nyckel", "wrong_location": "Inga Airly m\u00e4tstationer i detta omr\u00e5de." }, "step": { diff --git a/homeassistant/components/airnow/translations/sv.json b/homeassistant/components/airnow/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/airnow/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 85ee4bf6ae5..f97616c38fc 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from typing import Any from pyairvisual import CloudAPI, NodeSamba from pyairvisual.errors import ( @@ -70,7 +72,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._entry_data_for_reauth: dict[str, str] = {} + self._entry_data_for_reauth: Mapping[str, Any] = {} self._geo_id: str | None = None @property @@ -219,10 +221,10 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO}, ) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._entry_data_for_reauth = data - self._geo_id = async_get_geography_id(data) + self._entry_data_for_reauth = entry_data + self._geo_id = async_get_geography_id(entry_data) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/airvisual/translations/bg.json b/homeassistant/components/airvisual/translations/bg.json index 114a1547549..807b9556240 100644 --- a/homeassistant/components/airvisual/translations/bg.json +++ b/homeassistant/components/airvisual/translations/bg.json @@ -11,7 +11,8 @@ "step": { "geography_by_coords": { "data": { - "api_key": "API \u043a\u043b\u044e\u0447" + "api_key": "API \u043a\u043b\u044e\u0447", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430" } }, "geography_by_name": { diff --git a/homeassistant/components/airvisual/translations/sensor.he.json b/homeassistant/components/airvisual/translations/sensor.he.json index 7ed68fa47ca..5745fb051f6 100644 --- a/homeassistant/components/airvisual/translations/sensor.he.json +++ b/homeassistant/components/airvisual/translations/sensor.he.json @@ -1,5 +1,8 @@ { "state": { + "airvisual__pollutant_label": { + "p1": "PM10" + }, "airvisual__pollutant_level": { "good": "\u05d8\u05d5\u05d1", "unhealthy": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0", diff --git a/homeassistant/components/airvisual/translations/sensor.sv.json b/homeassistant/components/airvisual/translations/sensor.sv.json new file mode 100644 index 00000000000..f1fa0bbdcd8 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.sv.json @@ -0,0 +1,7 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Kolmonoxid" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sv.json b/homeassistant/components/airvisual/translations/sv.json index 6a33c0393d9..5273668aa12 100644 --- a/homeassistant/components/airvisual/translations/sv.json +++ b/homeassistant/components/airvisual/translations/sv.json @@ -5,6 +5,11 @@ "invalid_api_key": "Ogiltig API-nyckel" }, "step": { + "geography_by_name": { + "data": { + "api_key": "API-nyckel" + } + }, "node_pro": { "data": { "ip_address": "Enhets IP-adress / v\u00e4rdnamn", diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index e0d3bd6df09..e189ae741ad 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -3,7 +3,7 @@ "name": "Airzone", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airzone", - "requirements": ["aioairzone==0.4.4"], + "requirements": ["aioairzone==0.4.5"], "codeowners": ["@Noltari"], "iot_class": "local_polling", "loggers": ["aioairzone"] diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 048624641bd..af996c9f5b2 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,13 +1,16 @@ """The aladdin_connect component.""" +import asyncio import logging from typing import Final -from aladdin_connect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient +from aiohttp import ClientConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -20,9 +23,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] - acc = AladdinConnectClient(username, password) - if not await hass.async_add_executor_job(acc.login): - raise ConfigEntryAuthFailed("Incorrect Password") + acc = AladdinConnectClient(username, password, async_get_clientsession(hass)) + try: + if not await acc.login(): + raise ConfigEntryAuthFailed("Incorrect Password") + except (ClientConnectionError, asyncio.TimeoutError) as ex: + raise ConfigEntryNotReady("Can not connect to host") from ex + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = acc hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index 153a63ffb06..0d45ea9a8ef 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -1,10 +1,14 @@ """Config flow for Aladdin Connect cover integration.""" from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging from typing import Any -from aladdin_connect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectionError import voluptuous as vol from homeassistant import config_entries @@ -12,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -32,8 +37,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - acc = AladdinConnectClient(data[CONF_USERNAME], data[CONF_PASSWORD]) - login = await hass.async_add_executor_job(acc.login) + acc = AladdinConnectClient( + data[CONF_USERNAME], data[CONF_PASSWORD], async_get_clientsession(hass) + ) + login = await acc.login() + await acc.close() if not login: raise InvalidAuth @@ -44,9 +52,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 entry: config_entries.ConfigEntry | None - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Aladdin Connect.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) @@ -68,8 +74,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await validate_input(self.hass, data) + except InvalidAuth: errors["base"] = "invalid_auth" + + except (ClientConnectionError, asyncio.TimeoutError, ClientError): + errors["base"] = "cannot_connect" + else: self.hass.config_entries.async_update_entry( @@ -104,6 +115,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except InvalidAuth: errors["base"] = "invalid_auth" + except (ClientConnectionError, asyncio.TimeoutError, ClientError): + errors["base"] = "cannot_connect" + else: await self.async_set_unique_id( user_input["username"].lower(), raise_on_progress=False diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index da3e6b81663..9c03cd322b6 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,10 +1,11 @@ """Platform for the Aladdin Connect cover component.""" from __future__ import annotations +from datetime import timedelta import logging from typing import Any, Final -from aladdin_connect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient import voluptuous as vol from homeassistant.components.cover import ( @@ -34,6 +35,7 @@ _LOGGER: Final = logging.getLogger(__name__) PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} ) +SCAN_INTERVAL = timedelta(seconds=300) async def async_setup_platform( @@ -62,14 +64,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aladdin Connect platform.""" - acc = hass.data[DOMAIN][config_entry.entry_id] - doors = await hass.async_add_executor_job(acc.get_doors) - + acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] + doors = await acc.get_doors() if doors is None: raise PlatformNotReady("Error from Aladdin Connect getting doors") async_add_entities( - (AladdinDevice(acc, door) for door in doors), - update_before_add=True, + (AladdinDevice(acc, door, config_entry) for door in doors), ) @@ -79,27 +79,63 @@ class AladdinDevice(CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE _attr_supported_features = SUPPORTED_FEATURES - def __init__(self, acc: AladdinConnectClient, device: DoorDevice) -> None: + def __init__( + self, acc: AladdinConnectClient, device: DoorDevice, entry: ConfigEntry + ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc + self._device_id = device["device_id"] self._number = device["door_number"] self._attr_name = device["name"] self._attr_unique_id = f"{self._device_id}-{self._number}" - def close_cover(self, **kwargs: Any) -> None: + async def async_added_to_hass(self) -> None: + """Connect Aladdin Connect to the cloud.""" + + async def update_callback() -> None: + """Schedule a state update.""" + self.async_write_ha_state() + + self._acc.register_callback(update_callback, self._number) + await self._acc.get_doors(self._number) + + async def async_will_remove_from_hass(self) -> None: + """Close Aladdin Connect before removing.""" + await self._acc.close() + + async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" - self._acc.close_door(self._device_id, self._number) + await self._acc.close_door(self._device_id, self._number) - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - self._acc.open_door(self._device_id, self._number) + await self._acc.open_door(self._device_id, self._number) - def update(self) -> None: + async def async_update(self) -> None: """Update status of cover.""" - status = STATES_MAP.get( - self._acc.get_door_status(self._device_id, self._number) + await self._acc.get_doors(self._number) + + @property + def is_closed(self) -> bool | None: + """Update is closed attribute.""" + value = STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + if value is None: + return None + return value == STATE_CLOSED + + @property + def is_closing(self) -> bool: + """Update is closing attribute.""" + return ( + STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + == STATE_CLOSING + ) + + @property + def is_opening(self) -> bool: + """Update is opening attribute.""" + return ( + STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + == STATE_OPENING ) - self._attr_is_opening = status == STATE_OPENING - self._attr_is_closing = status == STATE_CLOSING - self._attr_is_closed = None if status is None else status == STATE_CLOSED diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index b9ea214d996..3a9e295a08f 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -2,7 +2,7 @@ "domain": "aladdin_connect", "name": "Aladdin Connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", - "requirements": ["aladdin_connect==0.4"], + "requirements": ["AIOAladdinConnect==0.1.21"], "codeowners": ["@mkmer"], "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], diff --git a/homeassistant/components/aladdin_connect/translations/es.json b/homeassistant/components/aladdin_connect/translations/es.json index 67e509e2626..ac10503ab3c 100644 --- a/homeassistant/components/aladdin_connect/translations/es.json +++ b/homeassistant/components/aladdin_connect/translations/es.json @@ -13,6 +13,7 @@ "data": { "password": "Contrase\u00f1a" }, + "description": "La integraci\u00f3n de Aladdin Connect necesita volver a autenticar su cuenta", "title": "Reautenticaci\u00f3n de la integraci\u00f3n" }, "user": { diff --git a/homeassistant/components/aladdin_connect/translations/pt-BR.json b/homeassistant/components/aladdin_connect/translations/pt-BR.json index 2d709bf1125..c1c6d0097cf 100644 --- a/homeassistant/components/aladdin_connect/translations/pt-BR.json +++ b/homeassistant/components/aladdin_connect/translations/pt-BR.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" }, "step": { @@ -19,7 +19,7 @@ "user": { "data": { "password": "Senha", - "username": "Nome de usu\u00e1rio" + "username": "Usu\u00e1rio" } } } diff --git a/homeassistant/components/aladdin_connect/translations/sv.json b/homeassistant/components/aladdin_connect/translations/sv.json new file mode 100644 index 00000000000..867d5d1c5c7 --- /dev/null +++ b/homeassistant/components/aladdin_connect/translations/sv.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "L\u00f6senord" + }, + "title": "\u00c5terautenticera integration" + }, + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/sv.json b/homeassistant/components/alarm_control_panel/translations/sv.json index 1f375eb5f1d..cff9e0cbc52 100644 --- a/homeassistant/components/alarm_control_panel/translations/sv.json +++ b/homeassistant/components/alarm_control_panel/translations/sv.json @@ -7,6 +7,9 @@ "disarm": "Avlarma {entity_name}", "trigger": "Utl\u00f6sare {entity_name}" }, + "condition_type": { + "is_triggered": "har utl\u00f6sts" + }, "trigger_type": { "armed_away": "{entity_name} larmad borta", "armed_home": "{entity_name} larmad hemma", diff --git a/homeassistant/components/alarm_control_panel/translations/zh-Hans.json b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json index e955d21afdb..1e9694ddb14 100644 --- a/homeassistant/components/alarm_control_panel/translations/zh-Hans.json +++ b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json @@ -4,6 +4,7 @@ "arm_away": "{entity_name} \u79bb\u5bb6\u8b66\u6212", "arm_home": "{entity_name} \u5728\u5bb6\u8b66\u6212", "arm_night": "{entity_name} \u591c\u95f4\u8b66\u6212", + "arm_vacation": "{entity_name} \u5ea6\u5047\u8b66\u6212", "disarm": "\u89e3\u9664 {entity_name} \u8b66\u6212", "trigger": "\u89e6\u53d1 {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} \u79bb\u5bb6\u8b66\u6212", "is_armed_home": "{entity_name} \u5728\u5bb6\u8b66\u6212", "is_armed_night": "{entity_name} \u591c\u95f4\u8b66\u6212", + "is_armed_vacation": "{entity_name} \u5ea6\u5047\u8b66\u6212", "is_disarmed": "{entity_name} \u8b66\u6212\u5df2\u89e3\u9664", "is_triggered": "{entity_name} \u8b66\u62a5\u5df2\u89e6\u53d1" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} \u79bb\u5bb6\u8b66\u6212", "armed_home": "{entity_name} \u5728\u5bb6\u8b66\u6212", "armed_night": "{entity_name} \u591c\u95f4\u8b66\u6212", + "armed_vacation": "{entity_name} \u5ea6\u5047\u8b66\u6212", "disarmed": "{entity_name} \u8b66\u6212\u89e3\u9664", "triggered": "{entity_name} \u89e6\u53d1\u8b66\u62a5" } diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 991d588eccf..ca11b9d6894 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for AlarmDecoder-based alarm control panels (Honeywell/DSC).""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.alarm_control_panel import ( @@ -91,7 +93,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): self._attr_code_arm_required = code_arm_required self._alt_night_mode = alt_night_mode - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -126,12 +128,12 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): } self.schedule_update_ha_state() - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code: self._client.send(f"{code!s}1") - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._client.arm_away( code=code, @@ -139,7 +141,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): auto_bypass=self._auto_bypass, ) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._client.arm_home( code=code, @@ -147,7 +149,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): auto_bypass=self._auto_bypass, ) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" self._client.arm_night( code=code, diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 37ff5b97994..52d17e407b7 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -1,4 +1,6 @@ """Config flow for AlarmDecoder.""" +from __future__ import annotations + import logging from adext import AdExt @@ -58,7 +60,9 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> AlarmDecoderOptionsFlowHandler: """Get the options flow for AlarmDecoder.""" return AlarmDecoderOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 5b675779a22..25ec43b689c 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -4,9 +4,11 @@ from __future__ import annotations import logging from homeassistant.components import ( + button, cover, fan, image_processing, + input_button, input_number, light, timer, @@ -1044,6 +1046,8 @@ class AlexaThermostatController(AlexaCapability): if preset in API_THERMOSTAT_PRESETS: mode = API_THERMOSTAT_PRESETS[preset] + elif self.entity.state == STATE_UNKNOWN: + return None else: mode = API_THERMOSTAT_MODES.get(self.entity.state) if mode is None: @@ -1891,7 +1895,10 @@ class AlexaEventDetectionSensor(AlexaCapability): if self.entity.domain == image_processing.DOMAIN: if int(state): human_presence = "DETECTED" - elif state == STATE_ON: + elif state == STATE_ON or self.entity.domain in [ + input_button.DOMAIN, + button.DOMAIN, + ]: human_presence = "DETECTED" return {"value": human_presence} @@ -1903,7 +1910,8 @@ class AlexaEventDetectionSensor(AlexaCapability): "detectionModes": { "humanPresence": { "featureAvailability": "ENABLED", - "supportsNotDetected": True, + "supportsNotDetected": self.entity.domain + not in [input_button.DOMAIN, button.DOMAIN], } }, } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 9ee4ad3411f..f380f990449 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -382,7 +382,6 @@ def async_get_entities(hass, config) -> list[AlexaEntity]: @ENTITY_ADAPTERS.register(alert.DOMAIN) @ENTITY_ADAPTERS.register(automation.DOMAIN) @ENTITY_ADAPTERS.register(group.DOMAIN) -@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) class GenericCapabilities(AlexaEntity): """A generic, on/off device. @@ -405,12 +404,16 @@ class GenericCapabilities(AlexaEntity): ] +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) @ENTITY_ADAPTERS.register(switch.DOMAIN) class SwitchCapabilities(AlexaEntity): """Class to represent Switch capabilities.""" def default_display_categories(self): """Return the display categories for this entity.""" + if self.entity.domain == input_boolean.DOMAIN: + return [DisplayCategory.OTHER] + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) if device_class == switch.SwitchDeviceClass.OUTLET: return [DisplayCategory.SMARTPLUG] @@ -421,6 +424,7 @@ class SwitchCapabilities(AlexaEntity): """Yield the supported interfaces.""" return [ AlexaPowerController(self.entity), + AlexaContactSensor(self.hass, self.entity), AlexaEndpointHealth(self.hass, self.entity), Alexa(self.hass), ] @@ -439,6 +443,8 @@ class ButtonCapabilities(AlexaEntity): """Yield the supported interfaces.""" return [ AlexaSceneController(self.entity, supports_deactivation=False), + AlexaEventDetectionSensor(self.hass, self.entity), + AlexaEndpointHealth(self.hass, self.entity), Alexa(self.hass), ] diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 7352bbd995a..ef145a9ceb8 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -127,11 +127,6 @@ async def async_handle_message(hass, message): @HANDLERS.register("SessionEndedRequest") -async def async_handle_session_end(hass, message): - """Handle a session end request.""" - return None - - @HANDLERS.register("IntentRequest") @HANDLERS.register("LaunchRequest") async def async_handle_intent(hass, message): @@ -151,6 +146,11 @@ async def async_handle_intent(hass, message): intent_name = ( message.get("session", {}).get("application", {}).get("applicationId") ) + elif req["type"] == "SessionEndedRequest": + app_id = message.get("session", {}).get("application", {}).get("applicationId") + intent_name = f"{app_id}.{req['type']}" + alexa_response.variables["reason"] = req["reason"] + alexa_response.variables["error"] = req.get("error") else: intent_name = alexa_intent_info["name"] diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index dfbdae219ca..11c883f4e0a 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -64,7 +64,7 @@ class AlmondFlowHandler( """Handle authorize step.""" result = await super().async_step_auth(user_input) - if result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP: + if result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP: self.host = str(URL(result["url"]).with_path("me")) return result diff --git a/homeassistant/components/ambee/config_flow.py b/homeassistant/components/ambee/config_flow.py index 0550c541ed0..7bfc1fa11af 100644 --- a/homeassistant/components/ambee/config_flow.py +++ b/homeassistant/components/ambee/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Ambee integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from ambee import Ambee, AmbeeAuthenticationError, AmbeeError @@ -71,7 +72,7 @@ class AmbeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Ambee.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/ambee/translations/sv.json b/homeassistant/components/ambee/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/ambee/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/translations/bg.json b/homeassistant/components/ambient_station/translations/bg.json index 173b1c39c5f..9e55323228f 100644 --- a/homeassistant/components/ambient_station/translations/bg.json +++ b/homeassistant/components/ambient_station/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, "error": { "invalid_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447 \u0438/\u0438\u043b\u0438 Application \u043a\u043b\u044e\u0447", "no_devices": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043f\u0440\u043e\u0444\u0438\u043b\u0430" diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 8a34b8aa858..4b203fc3757 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -60,7 +60,7 @@ def get_androidtv_mac(dev_props: dict[str, Any]) -> str | None: def _setup_androidtv( - hass: HomeAssistant, config: dict[str, Any] + hass: HomeAssistant, config: Mapping[str, Any] ) -> tuple[str, PythonRSASigner | None, str]: """Generate an ADB key (if needed) and load it.""" adbkey: str = config.get( diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ab43632e25c..ec1e13e07fc 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,7 +1,6 @@ """Rest API for Home Assistant.""" import asyncio from http import HTTPStatus -import json import logging from aiohttp import web @@ -29,7 +28,7 @@ import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized from homeassistant.helpers import template -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import json_dumps, json_loads from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType @@ -108,7 +107,7 @@ class APIEventStream(HomeAssistantView): if event.event_type == EVENT_HOMEASSISTANT_STOP: data = stop_obj else: - data = json.dumps(event, cls=JSONEncoder) + data = json_dumps(event) await to_write.put(data) @@ -261,7 +260,7 @@ class APIEventView(HomeAssistantView): raise Unauthorized() body = await request.text() try: - event_data = json.loads(body) if body else None + event_data = json_loads(body) if body else None except ValueError: return self.json_message( "Event data should be valid JSON.", HTTPStatus.BAD_REQUEST @@ -314,7 +313,7 @@ class APIDomainServicesView(HomeAssistantView): hass: ha.HomeAssistant = request.app["hass"] body = await request.text() try: - data = json.loads(body) if body else None + data = json_loads(body) if body else None except ValueError: return self.json_message( "Data should be valid JSON.", HTTPStatus.BAD_REQUEST diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index d61c21972fb..5177c6f3486 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -49,6 +50,13 @@ PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for Apple TV.""" manager = AppleTVManager(hass, entry) + + if manager.is_on: + await manager.connect_once(raise_missing_credentials=True) + if not manager.atv: + address = entry.data[CONF_ADDRESS] + raise ConfigEntryNotReady(f"Not found at {address}, waiting for discovery") + hass.data.setdefault(DOMAIN, {})[entry.unique_id] = manager async def on_hass_stop(event): @@ -115,6 +123,10 @@ class AppleTVEntity(Entity): self.atv = None self.async_write_ha_state() + if self.manager.atv: + # ATV is already connected + _async_connected(self.manager.atv) + self.async_on_remove( async_dispatcher_connect( self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected @@ -148,14 +160,14 @@ class AppleTVManager: self.config_entry = config_entry self.hass = hass self.atv = None - self._is_on = not config_entry.options.get(CONF_START_OFF, False) + self.is_on = not config_entry.options.get(CONF_START_OFF, False) self._connection_attempts = 0 self._connection_was_lost = False self._task = None async def init(self): """Initialize power management.""" - if self._is_on: + if self.is_on: await self.connect() def connection_lost(self, _): @@ -186,13 +198,13 @@ class AppleTVManager: async def connect(self): """Connect to device.""" - self._is_on = True + self.is_on = True self._start_connect_loop() async def disconnect(self): """Disconnect from device.""" _LOGGER.debug("Disconnecting from device") - self._is_on = False + self.is_on = False try: if self.atv: self.atv.close() @@ -205,50 +217,53 @@ class AppleTVManager: def _start_connect_loop(self): """Start background connect loop to device.""" - if not self._task and self.atv is None and self._is_on: + if not self._task and self.atv is None and self.is_on: self._task = asyncio.create_task(self._connect_loop()) else: _LOGGER.debug( - "Not starting connect loop (%s, %s)", self.atv is None, self._is_on + "Not starting connect loop (%s, %s)", self.atv is None, self.is_on ) + async def connect_once(self, raise_missing_credentials): + """Try to connect once.""" + try: + if conf := await self._scan(): + await self._connect(conf, raise_missing_credentials) + except exceptions.AuthenticationError: + self.config_entry.async_start_reauth(self.hass) + asyncio.create_task(self.disconnect()) + _LOGGER.exception( + "Authentication failed for %s, try reconfiguring device", + self.config_entry.data[CONF_NAME], + ) + return + except asyncio.CancelledError: + pass + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to connect") + self.atv = None + async def _connect_loop(self): """Connect loop background task function.""" _LOGGER.debug("Starting connect loop") # Try to find device and connect as long as the user has said that # we are allowed to connect and we are not already connected. - while self._is_on and self.atv is None: - try: - conf = await self._scan() - if conf: - await self._connect(conf) - except exceptions.AuthenticationError: - self.config_entry.async_start_reauth(self.hass) - asyncio.create_task(self.disconnect()) - _LOGGER.exception( - "Authentication failed for %s, try reconfiguring device", - self.config_entry.data[CONF_NAME], - ) + while self.is_on and self.atv is None: + await self.connect_once(raise_missing_credentials=False) + if self.atv is not None: break - except asyncio.CancelledError: - pass - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to connect") - self.atv = None + self._connection_attempts += 1 + backoff = min( + max( + BACKOFF_TIME_LOWER_LIMIT, + randrange(2**self._connection_attempts), + ), + BACKOFF_TIME_UPPER_LIMIT, + ) - if self.atv is None: - self._connection_attempts += 1 - backoff = min( - max( - BACKOFF_TIME_LOWER_LIMIT, - randrange(2**self._connection_attempts), - ), - BACKOFF_TIME_UPPER_LIMIT, - ) - - _LOGGER.debug("Reconnecting in %d seconds", backoff) - await asyncio.sleep(backoff) + _LOGGER.debug("Reconnecting in %d seconds", backoff) + await asyncio.sleep(backoff) _LOGGER.debug("Connect loop ended") self._task = None @@ -287,23 +302,33 @@ class AppleTVManager: # it will update the address and reload the config entry when the device is found. return None - async def _connect(self, conf): + async def _connect(self, conf, raise_missing_credentials): """Connect to device.""" credentials = self.config_entry.data[CONF_CREDENTIALS] - session = async_get_clientsession(self.hass) - + name = self.config_entry.data[CONF_NAME] + missing_protocols = [] for protocol_int, creds in credentials.items(): protocol = Protocol(int(protocol_int)) if conf.get_service(protocol) is not None: conf.set_credentials(protocol, creds) else: - _LOGGER.warning( - "Protocol %s not found for %s, functionality will be reduced", - protocol.name, - self.config_entry.data[CONF_NAME], + missing_protocols.append(protocol.name) + + if missing_protocols: + missing_protocols_str = ", ".join(missing_protocols) + if raise_missing_credentials: + raise ConfigEntryNotReady( + f"Protocol(s) {missing_protocols_str} not yet found for {name}, waiting for discovery." ) + _LOGGER.info( + "Protocol(s) %s not yet found for %s, trying later", + missing_protocols_str, + name, + ) + return _LOGGER.debug("Connecting to device %s", self.config_entry.data[CONF_NAME]) + session = async_get_clientsession(self.hass) self.atv = await connect(conf, self.hass.loop, session=session) self.atv.listener = self diff --git a/homeassistant/components/apple_tv/browse_media.py b/homeassistant/components/apple_tv/browse_media.py index 8d0a94ca858..0673c9923fb 100644 --- a/homeassistant/components/apple_tv/browse_media.py +++ b/homeassistant/components/apple_tv/browse_media.py @@ -21,8 +21,8 @@ def build_app_list(app_list): media_content_id="apps", media_content_type=MEDIA_TYPE_APPS, title="Apps", - can_play=True, - can_expand=False, + can_play=False, + can_expand=True, children=[item_payload(item) for item in app_list], children_media_class=MEDIA_CLASS_APP, ) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 8e8e6006895..b78add3260e 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio from collections import deque +from collections.abc import Mapping from ipaddress import ip_address import logging from random import randrange +from typing import Any from pyatv import exceptions, pair, scan from pyatv.const import DeviceModel, PairingRequirement, Protocol @@ -13,10 +15,11 @@ from pyatv.convert import model_str, protocol_str from pyatv.helpers import get_unique_id import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.network import is_ipv6_address @@ -71,7 +74,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> AppleTVOptionsFlow: """Get options flow for this handler.""" return AppleTVOptionsFlow(config_entry) @@ -116,10 +121,10 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return entry.unique_id return None - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initial step when updating invalid credentials.""" self.context["title_placeholders"] = { - "name": user_input[CONF_NAME], + "name": entry_data[CONF_NAME], "type": "Apple TV", } self.scan_filter = self.unique_id @@ -164,7 +169,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle device found via zeroconf.""" host = discovery_info.host if is_ipv6_address(host): @@ -248,7 +253,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): # Add potentially new identifiers from this device to the existing flow context["all_identifiers"].append(unique_id) - raise data_entry_flow.AbortFlow("already_in_progress") + raise AbortFlow("already_in_progress") async def async_found_zeroconf_device(self, user_input=None): """Handle device found after Zeroconf discovery.""" @@ -523,7 +528,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class AppleTVOptionsFlow(config_entries.OptionsFlow): """Handle Apple TV options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Apple TV options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 26a8e2737c8..dec195fddee 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -3,7 +3,7 @@ "name": "Apple TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/apple_tv", - "requirements": ["pyatv==0.10.0"], + "requirements": ["pyatv==0.10.2"], "dependencies": ["zeroconf"], "zeroconf": [ "_mediaremotetv._tcp.local.", diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 30a397d953c..362a09fb5fc 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -282,22 +282,20 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # RAOP. Otherwise try to play it with regular AirPlay. if media_type == MEDIA_TYPE_APP: await self.atv.apps.launch_app(media_id) + return if media_source.is_media_source_id(media_id): play_item = await media_source.async_resolve_media( self.hass, media_id, self.entity_id ) - media_id = play_item.url + media_id = async_process_play_media_url(self.hass, play_item.url) media_type = MEDIA_TYPE_MUSIC - media_id = async_process_play_media_url(self.hass, media_id) - if self._is_feature_available(FeatureName.StreamFile) and ( media_type == MEDIA_TYPE_MUSIC or await is_streamable(media_id) ): _LOGGER.debug("Streaming %s via RAOP", media_id) await self.atv.stream.stream_file(media_id) - elif self._is_feature_available(FeatureName.PlayUrl): _LOGGER.debug("Playing %s via AirPlay", media_id) await self.atv.stream.play_url(media_id) diff --git a/homeassistant/components/apple_tv/translations/es.json b/homeassistant/components/apple_tv/translations/es.json index 1a0ed773169..3fe2345d6e1 100644 --- a/homeassistant/components/apple_tv/translations/es.json +++ b/homeassistant/components/apple_tv/translations/es.json @@ -7,6 +7,7 @@ "device_did_not_pair": "No se ha intentado finalizar el proceso de emparejamiento desde el dispositivo.", "device_not_found": "No se ha encontrado el dispositivo durante la detecci\u00f3n, por favor, intente a\u00f1adirlo de nuevo.", "inconsistent_device": "No se encontraron los protocolos esperados durante el descubrimiento. Esto normalmente indica un problema con el DNS de multidifusi\u00f3n (Zeroconf). Por favor, intente a\u00f1adir el dispositivo de nuevo.", + "ipv6_not_supported": "IPv6 no est\u00e1 soportado.", "no_devices_found": "No se encontraron dispositivos en la red", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", "setup_failed": "No se ha podido configurar el dispositivo.", diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 1a128c5c378..14ae049cfca 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -253,6 +253,11 @@ class ApplicationCredentialsProtocol(Protocol): ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: """Return a custom auth implementation.""" + async def async_get_description_placeholders( + self, hass: HomeAssistant + ) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + async def _get_platform( hass: HomeAssistant, integration_domain: str @@ -282,6 +287,14 @@ async def _get_platform( return platform +async def _async_integration_config(hass: HomeAssistant, domain: str) -> dict[str, Any]: + platform = await _get_platform(hass, domain) + if platform and hasattr(platform, "async_get_description_placeholders"): + placeholders = await platform.async_get_description_placeholders(hass) + return {"description_placeholders": placeholders} + return {} + + @websocket_api.websocket_command( {vol.Required("type"): "application_credentials/config"} ) @@ -290,6 +303,11 @@ async def handle_integration_list( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" - connection.send_result( - msg["id"], {"domains": await async_get_application_credentials(hass)} - ) + domains = await async_get_application_credentials(hass) + result = { + "domains": domains, + "integrations": { + domain: await _async_integration_config(hass, domain) for domain in domains + }, + } + connection.send_result(msg["id"], result) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index b4422a49ef4..450e0a964df 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.9.8.3"], + "requirements": ["apprise==0.9.9"], "codeowners": ["@caronc"], "iot_class": "cloud_push", "loggers": ["apprise"] diff --git a/homeassistant/components/arcam_fmj/translations/zh-Hans.json b/homeassistant/components/arcam_fmj/translations/zh-Hans.json index 6e842e66fab..68057bbb8a1 100644 --- a/homeassistant/components/arcam_fmj/translations/zh-Hans.json +++ b/homeassistant/components/arcam_fmj/translations/zh-Hans.json @@ -3,5 +3,10 @@ "abort": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25" } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} \u88ab\u8981\u6c42\u6253\u5f00" + } } } \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/bg.json b/homeassistant/components/asuswrt/translations/bg.json index dbb5f415f92..df452e48980 100644 --- a/homeassistant/components/asuswrt/translations/bg.json +++ b/homeassistant/components/asuswrt/translations/bg.json @@ -1,11 +1,13 @@ { "config": { "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { "data": { + "mode": "\u0420\u0435\u0436\u0438\u043c", "name": "\u0418\u043c\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "port": "\u041f\u043e\u0440\u0442", diff --git a/homeassistant/components/asuswrt/translations/es.json b/homeassistant/components/asuswrt/translations/es.json index 9a2e0485aa7..a2e899ef113 100644 --- a/homeassistant/components/asuswrt/translations/es.json +++ b/homeassistant/components/asuswrt/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "invalid_unique_id": "No se pudo determinar ning\u00fan identificador \u00fanico v\u00e1lido del dispositivo" + "invalid_unique_id": "No se pudo determinar ning\u00fan identificador \u00fanico v\u00e1lido del dispositivo", + "no_unique_id": "Un dispositivo sin una identificaci\u00f3n \u00fanica v\u00e1lida ya est\u00e1 configurado. La configuraci\u00f3n de una instancia m\u00faltiple no es posible" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/asuswrt/translations/pt-BR.json b/homeassistant/components/asuswrt/translations/pt-BR.json index a42cab6fe0d..e72feee12d8 100644 --- a/homeassistant/components/asuswrt/translations/pt-BR.json +++ b/homeassistant/components/asuswrt/translations/pt-BR.json @@ -2,7 +2,7 @@ "config": { "abort": { "invalid_unique_id": "Imposs\u00edvel determinar um ID exclusivo v\u00e1lido para o dispositivo", - "no_unique_id": "[%key:component::asuswrt::config::abort::not_unique_id_exist%]" + "no_unique_id": "Um dispositivo sem um ID exclusivo v\u00e1lido j\u00e1 est\u00e1 configurado. A configura\u00e7\u00e3o de v\u00e1rias inst\u00e2ncias n\u00e3o \u00e9 poss\u00edvel" }, "error": { "cannot_connect": "Falha ao conectar", diff --git a/homeassistant/components/asuswrt/translations/sv.json b/homeassistant/components/asuswrt/translations/sv.json index 057a107356a..4e7196b1b21 100644 --- a/homeassistant/components/asuswrt/translations/sv.json +++ b/homeassistant/components/asuswrt/translations/sv.json @@ -31,7 +31,7 @@ "step": { "init": { "data": { - "consider_home": "Sekunder att v\u00e4nta tills attt en enhet anses borta", + "consider_home": "Sekunder att v\u00e4nta tills att en enhet anses borta", "dnsmasq": "Platsen i routern f\u00f6r dnsmasq.leases-filerna", "interface": "Gr\u00e4nssnittet som du vill ha statistik fr\u00e5n (t.ex. eth0, eth1 etc)", "require_ip": "Enheterna m\u00e5ste ha IP (f\u00f6r accesspunktsl\u00e4ge)", diff --git a/homeassistant/components/atag/translations/bg.json b/homeassistant/components/atag/translations/bg.json index 2dd2ff1750c..0d30d7c1e16 100644 --- a/homeassistant/components/atag/translations/bg.json +++ b/homeassistant/components/atag/translations/bg.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 570ec5983fe..e8df7e1072d 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers import device_registry as dr from .activity import ActivityStream from .const import DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS @@ -283,12 +284,15 @@ class AugustData(AugustSubscriberMixin): device.device_id, ) - def _get_device_name(self, device_id): + def get_device(self, device_id: str) -> Doorbell | Lock | None: + """Get a device by id.""" + return self._locks_by_id.get(device_id) or self._doorbells_by_id.get(device_id) + + def _get_device_name(self, device_id: str) -> str | None: """Return doorbell or lock name as August has it stored.""" - if device_id in self._locks_by_id: - return self._locks_by_id[device_id].device_name - if device_id in self._doorbells_by_id: - return self._doorbells_by_id[device_id].device_name + if device := self.get_device(device_id): + return device.device_name + return None async def async_lock(self, device_id): """Lock the device.""" @@ -403,3 +407,15 @@ def _restore_live_attrs(lock_detail, attrs): """Restore the non-cache attributes after a cached update.""" for attr, value in attrs.items(): setattr(lock_detail, attr, value) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove august config entry from a device if its no longer present.""" + data: AugustData = hass.data[DOMAIN][config_entry.entry_id] + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN and data.get_device(identifier[1]) + ) diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index 5f4032153a2..c96db61ca1a 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -30,7 +30,7 @@ class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): self._attr_name = f"{device.device_name} Wake" self._attr_unique_id = f"{self._device_id}_wake" - async def async_press(self, **kwargs): + async def async_press(self) -> None: """Wake the device.""" await self._data.async_status_async(self._device_id, self._hyper_bridge) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index eb7bac9ae1a..067f986c4e6 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -1,11 +1,14 @@ """Config flow for August integration.""" +from collections.abc import Mapping import logging +from typing import Any import voluptuous as vol from yalexs.authenticator import ValidationResult from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from .const import CONF_LOGIN_METHOD, DOMAIN, LOGIN_METHODS, VERIFICATION_CODE_KEY from .exceptions import CannotConnect, InvalidAuth, RequireValidation @@ -109,9 +112,9 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._user_auth_details = dict(data) + self._user_auth_details = dict(entry_data) self._mode = "reauth" self._needs_reset = True self._august_gateway = AugustGateway(self.hass) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index c993cf03b89..d77a61a0659 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,5 +1,6 @@ """Support for August lock.""" import logging +from typing import Any from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType @@ -44,14 +45,14 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): self._attr_unique_id = f"{self._device_id:s}_lock" self._update_from_data() - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" if self._data.activity_stream.pubnub.connected: await self._data.async_lock_async(self._device_id, self._hyper_bridge) return await self._call_lock_operation(self._data.async_lock) - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" if self._data.activity_stream.pubnub.connected: await self._data.async_unlock_async(self._device_id, self._hyper_bridge) @@ -126,7 +127,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): "keypad_battery_level" ] = self._detail.keypad.battery_level - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" await super().async_added_to_hass() diff --git a/homeassistant/components/august/translations/bg.json b/homeassistant/components/august/translations/bg.json new file mode 100644 index 00000000000..224e3324cb6 --- /dev/null +++ b/homeassistant/components/august/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "user_validate": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index fd9ebbf424c..a2331c19ec6 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -1,4 +1,6 @@ """Config flow for SpaceX Launches and Starman.""" +from __future__ import annotations + import logging from aiohttp import ClientError @@ -22,7 +24,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -82,7 +86,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow changes.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/aurora/translations/ja.json b/homeassistant/components/aurora/translations/ja.json index a4d9b830968..455ceceacac 100644 --- a/homeassistant/components/aurora/translations/ja.json +++ b/homeassistant/components/aurora/translations/ja.json @@ -17,7 +17,7 @@ "step": { "init": { "data": { - "threshold": "\u3057\u304d\u3044\u5024(%)" + "threshold": "\u95be\u5024(%)" } } } diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 585f3720144..c988121b6bd 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -10,15 +10,13 @@ import logging -from aurorapy.client import AuroraError, AuroraSerialClient +from aurorapy.client import AuroraSerialClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from .config_flow import validate_and_connect -from .const import ATTR_SERIAL_NUMBER, DOMAIN +from .const import DOMAIN PLATFORMS = [Platform.SENSOR] @@ -31,42 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: comport = entry.data[CONF_PORT] address = entry.data[CONF_ADDRESS] ser_client = AuroraSerialClient(address, comport, parity="N", timeout=1) - # To handle yaml import attempts in darkness, (re)try connecting only if - # unique_id not yet assigned. - if entry.unique_id is None: - try: - res = await hass.async_add_executor_job( - validate_and_connect, hass, entry.data - ) - except AuroraError as error: - if "No response after" in str(error): - raise ConfigEntryNotReady("No response (could be dark)") from error - _LOGGER.error("Failed to connect to inverter: %s", error) - return False - except OSError as error: - if error.errno == 19: # No such device. - _LOGGER.error("Failed to connect to inverter: no such COM port") - return False - _LOGGER.error("Failed to connect to inverter: %s", error) - return False - else: - # If we got here, the device is now communicating (maybe after - # being in darkness). But there's a small risk that the user has - # configured via the UI since we last attempted the yaml setup, - # which means we'd get a duplicate unique ID. - new_id = res[ATTR_SERIAL_NUMBER] - # Check if this unique_id has already been used - for existing_entry in hass.config_entries.async_entries(DOMAIN): - if existing_entry.unique_id == new_id: - _LOGGER.debug( - "Remove already configured config entry for id %s", new_id - ) - hass.async_create_task( - hass.config_entries.async_remove(entry.entry_id) - ) - return False - hass.config_entries.async_update_entry(entry, unique_id=new_id) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ser_client hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json index 056f2dc98c2..1207932ae1a 100644 --- a/homeassistant/components/aurora_abb_powerone/manifest.json +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -3,7 +3,7 @@ "name": "Aurora ABB PowerOne Solar PV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone", - "requirements": ["aurorapy==0.2.6"], + "requirements": ["aurorapy==0.2.7"], "codeowners": ["@davet2001"], "iot_class": "local_polling", "loggers": ["aurorapy"] diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index c06cd7bc5a7..188f1c789a2 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -from aurorapy.client import AuroraError, AuroraSerialClient +from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -102,22 +102,16 @@ class AuroraSensor(AuroraEntity, SensorEntity): self._attr_native_value = round(energy_wh / 1000, 2) self._attr_available = True + except AuroraTimeoutError: + self._attr_state = None + self._attr_native_value = None + self._attr_available = False + _LOGGER.debug("No response from inverter (could be dark)") except AuroraError as error: self._attr_state = None self._attr_native_value = None self._attr_available = False - # aurorapy does not have different exceptions (yet) for dealing - # with timeout vs other comms errors. - # This means the (normal) situation of no response during darkness - # raises an exception. - # aurorapy (gitlab) pull request merged 29/5/2019. When >0.2.6 is - # released, this could be modified to : - # except AuroraTimeoutError as e: - # Workaround: look at the text of the exception - if "No response after" in str(error): - _LOGGER.debug("No response from inverter (could be dark)") - else: - raise error + raise error finally: if self._attr_available != self.available_prev: if self._attr_available: diff --git a/homeassistant/components/aurora_abb_powerone/translations/sv.json b/homeassistant/components/aurora_abb_powerone/translations/sv.json new file mode 100644 index 00000000000..361fc8bbbb7 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "no_serial_ports": "Inga com portar funna. M\u00e5ste ha en RS485 enhet f\u00f6r att kommunicera" + }, + "error": { + "cannot_open_serial_port": "Kan inte \u00f6ppna serieporten, kontrollera och f\u00f6rs\u00f6k igen." + }, + "step": { + "user": { + "data": { + "port": "RS485 eller USB-RS485 adapter port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aussie_broadband/config_flow.py b/homeassistant/components/aussie_broadband/config_flow.py index 6e101250386..c71570b73fb 100644 --- a/homeassistant/components/aussie_broadband/config_flow.py +++ b/homeassistant/components/aussie_broadband/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Aussie Broadband integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from aiohttp import ClientError @@ -76,9 +77,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth on credential failure.""" - self._reauth_username = user_input[CONF_USERNAME] + self._reauth_username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/aussie_broadband/translations/es.json b/homeassistant/components/aussie_broadband/translations/es.json index 1410af6ad76..497215525cb 100644 --- a/homeassistant/components/aussie_broadband/translations/es.json +++ b/homeassistant/components/aussie_broadband/translations/es.json @@ -1,12 +1,14 @@ { "config": { "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", "no_services_found": "No se han encontrado servicios para esta cuenta", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { @@ -31,8 +33,16 @@ } }, "options": { + "abort": { + "cannot_connect": "Fallo en la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, "step": { "init": { + "data": { + "services": "Servicios" + }, "title": "Selecciona servicios" } } diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 10f974faa28..897ca037c98 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -150,44 +150,6 @@ from homeassistant.util import dt as dt_util from . import indieauth, login_flow, mfa_setup_flow DOMAIN = "auth" -WS_TYPE_CURRENT_USER = "auth/current_user" -SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_CURRENT_USER} -) - -WS_TYPE_LONG_LIVED_ACCESS_TOKEN = "auth/long_lived_access_token" -SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_LONG_LIVED_ACCESS_TOKEN, - vol.Required("lifespan"): int, # days - vol.Required("client_name"): str, - vol.Optional("client_icon"): str, - } -) - -WS_TYPE_REFRESH_TOKENS = "auth/refresh_tokens" -SCHEMA_WS_REFRESH_TOKENS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_REFRESH_TOKENS} -) - -WS_TYPE_DELETE_REFRESH_TOKEN = "auth/delete_refresh_token" -SCHEMA_WS_DELETE_REFRESH_TOKEN = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_DELETE_REFRESH_TOKEN, - vol.Required("refresh_token_id"): str, - } -) - -WS_TYPE_SIGN_PATH = "auth/sign_path" -SCHEMA_WS_SIGN_PATH = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_SIGN_PATH, - vol.Required("path"): str, - vol.Optional("expires", default=30): int, - } -) - -RESULT_TYPE_CREDENTIALS = "credentials" @bind_hass @@ -206,27 +168,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(LinkUserView(retrieve_result)) hass.http.register_view(OAuth2AuthorizeCallbackView()) - websocket_api.async_register_command( - hass, WS_TYPE_CURRENT_USER, websocket_current_user, SCHEMA_WS_CURRENT_USER - ) - websocket_api.async_register_command( - hass, - WS_TYPE_LONG_LIVED_ACCESS_TOKEN, - websocket_create_long_lived_access_token, - SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN, - ) - websocket_api.async_register_command( - hass, WS_TYPE_REFRESH_TOKENS, websocket_refresh_tokens, SCHEMA_WS_REFRESH_TOKENS - ) - websocket_api.async_register_command( - hass, - WS_TYPE_DELETE_REFRESH_TOKEN, - websocket_delete_refresh_token, - SCHEMA_WS_DELETE_REFRESH_TOKEN, - ) - websocket_api.async_register_command( - hass, WS_TYPE_SIGN_PATH, websocket_sign_path, SCHEMA_WS_SIGN_PATH - ) + websocket_api.async_register_command(hass, websocket_current_user) + websocket_api.async_register_command(hass, websocket_create_long_lived_access_token) + websocket_api.async_register_command(hass, websocket_refresh_tokens) + websocket_api.async_register_command(hass, websocket_delete_refresh_token) + websocket_api.async_register_command(hass, websocket_sign_path) await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) @@ -487,6 +433,7 @@ def _create_auth_code_store(): return store_result, retrieve_result +@websocket_api.websocket_command({vol.Required("type"): "auth/current_user"}) @websocket_api.ws_require_user() @websocket_api.async_response async def websocket_current_user( @@ -524,6 +471,14 @@ async def websocket_current_user( ) +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/long_lived_access_token", + vol.Required("lifespan"): int, # days + vol.Required("client_name"): str, + vol.Optional("client_icon"): str, + } +) @websocket_api.ws_require_user() @websocket_api.async_response async def websocket_create_long_lived_access_token( @@ -541,13 +496,13 @@ async def websocket_create_long_lived_access_token( try: access_token = hass.auth.async_create_access_token(refresh_token) except InvalidAuthError as exc: - return websocket_api.error_message( - msg["id"], websocket_api.const.ERR_UNAUTHORIZED, str(exc) - ) + connection.send_error(msg["id"], websocket_api.const.ERR_UNAUTHORIZED, str(exc)) + return - connection.send_message(websocket_api.result_message(msg["id"], access_token)) + connection.send_result(msg["id"], access_token) +@websocket_api.websocket_command({vol.Required("type"): "auth/refresh_tokens"}) @websocket_api.ws_require_user() @callback def websocket_refresh_tokens( @@ -555,27 +510,38 @@ def websocket_refresh_tokens( ): """Return metadata of users refresh tokens.""" current_id = connection.refresh_token_id - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "id": refresh.id, - "client_id": refresh.client_id, - "client_name": refresh.client_name, - "client_icon": refresh.client_icon, - "type": refresh.token_type, - "created_at": refresh.created_at, - "is_current": refresh.id == current_id, - "last_used_at": refresh.last_used_at, - "last_used_ip": refresh.last_used_ip, - } - for refresh in connection.user.refresh_tokens.values() - ], + + tokens = [] + for refresh in connection.user.refresh_tokens.values(): + if refresh.credential: + auth_provider_type = refresh.credential.auth_provider_type + else: + auth_provider_type = None + + tokens.append( + { + "id": refresh.id, + "client_id": refresh.client_id, + "client_name": refresh.client_name, + "client_icon": refresh.client_icon, + "type": refresh.token_type, + "created_at": refresh.created_at, + "is_current": refresh.id == current_id, + "last_used_at": refresh.last_used_at, + "last_used_ip": refresh.last_used_ip, + "auth_provider_type": auth_provider_type, + } ) - ) + + connection.send_result(msg["id"], tokens) +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/delete_refresh_token", + vol.Required("refresh_token_id"): str, + } +) @websocket_api.ws_require_user() @websocket_api.async_response async def websocket_delete_refresh_token( @@ -585,15 +551,21 @@ async def websocket_delete_refresh_token( refresh_token = connection.user.refresh_tokens.get(msg["refresh_token_id"]) if refresh_token is None: - return websocket_api.error_message( - msg["id"], "invalid_token_id", "Received invalid token" - ) + connection.send_error(msg["id"], "invalid_token_id", "Received invalid token") + return await hass.auth.async_remove_refresh_token(refresh_token) - connection.send_message(websocket_api.result_message(msg["id"], {})) + connection.send_result(msg["id"], {}) +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/sign_path", + vol.Required("path"): str, + vol.Optional("expires", default=30): int, + } +) @websocket_api.ws_require_user() @callback def websocket_sign_path( diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index cd6a405d42c..b24da92afdd 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -52,7 +52,7 @@ flow for details. Progress the flow. Most flows will be 1 page, but could optionally add extra login challenges, like TFA. Once the flow has finished, the returned step will -have type RESULT_TYPE_CREATE_ENTRY and "result" key will contain an authorization code. +have type FlowResultType.CREATE_ENTRY and "result" key will contain an authorization code. The authorization code associated with an authorized user by default, it will associate with an credential if "type" set to "link_user" in "/auth/login_flow" @@ -123,13 +123,13 @@ class AuthProvidersView(HomeAssistantView): def _prepare_result_json(result): """Convert result to JSON.""" - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: data = result.copy() data.pop("result") data.pop("data") return data - if result["type"] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.FlowResultType.FORM: return result data = result.copy() @@ -154,11 +154,11 @@ class LoginFlowBaseView(HomeAssistantView): async def _async_flow_result_to_response(self, request, client_id, result): """Convert the flow result to a response.""" - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200. # We need to manually log failed login attempts. if ( - result["type"] == data_entry_flow.RESULT_TYPE_FORM + result["type"] == data_entry_flow.FlowResultType.FORM and (errors := result.get("errors")) and errors.get("base") in ( diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index aa45cc1b028..e288fe33df7 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -129,11 +129,11 @@ def websocket_depose_mfa( def _prepare_result_json(result): """Convert result to JSON.""" - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: data = result.copy() return data - if result["type"] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.FlowResultType.FORM: return result data = result.copy() diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 1eff98dd78d..1e83144945d 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -1,12 +1,16 @@ """Config flow for Awair.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from python_awair import Awair from python_awair.exceptions import AuthError, AwairError import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -17,7 +21,9 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input: dict | None = None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} @@ -42,8 +48,14 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input: dict | None = None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-auth if token invalid.""" + 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.""" errors = {} if user_input is not None: @@ -62,7 +74,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): errors = {CONF_ACCESS_TOKEN: error} return self.async_show_form( - step_id="reauth", + step_id="reauth_confirm", data_schema=vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}), errors=errors, ) diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json index f9b1f40e047..5ed7c0e715e 100644 --- a/homeassistant/components/awair/strings.json +++ b/homeassistant/components/awair/strings.json @@ -8,7 +8,7 @@ "email": "[%key:common::config_flow::data::email%]" } }, - "reauth": { + "reauth_confirm": { "description": "Please re-enter your Awair developer access token.", "data": { "access_token": "[%key:common::config_flow::data::access_token%]", diff --git a/homeassistant/components/awair/translations/ca.json b/homeassistant/components/awair/translations/ca.json index 12384b088bb..3510d3a3a8b 100644 --- a/homeassistant/components/awair/translations/ca.json +++ b/homeassistant/components/awair/translations/ca.json @@ -17,6 +17,13 @@ }, "description": "Torna a introduir el token d'acc\u00e9s de desenvolupador d'Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Token d'acc\u00e9s", + "email": "Correu electr\u00f2nic" + }, + "description": "Torna a introduir el 'token' d'acc\u00e9s de desenvolupador d'Awair." + }, "user": { "data": { "access_token": "Token d'acc\u00e9s", diff --git a/homeassistant/components/awair/translations/de.json b/homeassistant/components/awair/translations/de.json index 1dacaf099dc..c28ee6bc016 100644 --- a/homeassistant/components/awair/translations/de.json +++ b/homeassistant/components/awair/translations/de.json @@ -15,7 +15,14 @@ "access_token": "Zugangstoken", "email": "E-Mail" }, - "description": "Bitte gib dein Awair-Entwicklerzugriffstoken erneut ein." + "description": "Bitte gib deinen Awair-Entwicklerzugriffstoken erneut ein." + }, + "reauth_confirm": { + "data": { + "access_token": "Zugangstoken", + "email": "E-Mail" + }, + "description": "Bitte gib deinen Awair-Entwicklerzugriffstoken erneut ein." }, "user": { "data": { diff --git a/homeassistant/components/awair/translations/en.json b/homeassistant/components/awair/translations/en.json index 0e5a1e62bb5..caec592c527 100644 --- a/homeassistant/components/awair/translations/en.json +++ b/homeassistant/components/awair/translations/en.json @@ -17,6 +17,13 @@ }, "description": "Please re-enter your Awair developer access token." }, + "reauth_confirm": { + "data": { + "access_token": "Access Token", + "email": "Email" + }, + "description": "Please re-enter your Awair developer access token." + }, "user": { "data": { "access_token": "Access Token", diff --git a/homeassistant/components/awair/translations/et.json b/homeassistant/components/awair/translations/et.json index 374db23e18e..70632b292e4 100644 --- a/homeassistant/components/awair/translations/et.json +++ b/homeassistant/components/awair/translations/et.json @@ -17,6 +17,13 @@ }, "description": "Taassisesta oma Awairi arendaja juurdep\u00e4\u00e4suluba." }, + "reauth_confirm": { + "data": { + "access_token": "Juurdep\u00e4\u00e4sut\u00f5end", + "email": "Meiliaadress" + }, + "description": "Taassisesta oma Awairi arendaja juurdep\u00e4\u00e4suluba." + }, "user": { "data": { "access_token": "Juurdep\u00e4\u00e4sut\u00f5end", diff --git a/homeassistant/components/awair/translations/fr.json b/homeassistant/components/awair/translations/fr.json index 7182117fa53..fd915507762 100644 --- a/homeassistant/components/awair/translations/fr.json +++ b/homeassistant/components/awair/translations/fr.json @@ -17,6 +17,13 @@ }, "description": "Veuillez ressaisir votre jeton d'acc\u00e8s d\u00e9veloppeur Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Jeton d'acc\u00e8s", + "email": "Courriel" + }, + "description": "Veuillez ressaisir votre jeton d'acc\u00e8s d\u00e9veloppeur Awair." + }, "user": { "data": { "access_token": "Jeton d'acc\u00e8s", diff --git a/homeassistant/components/awair/translations/hu.json b/homeassistant/components/awair/translations/hu.json index e3994430a8b..2e81b31f187 100644 --- a/homeassistant/components/awair/translations/hu.json +++ b/homeassistant/components/awair/translations/hu.json @@ -17,6 +17,13 @@ }, "description": "Adja meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." }, + "reauth_confirm": { + "data": { + "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", + "email": "E-mail" + }, + "description": "Adja meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." + }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", diff --git a/homeassistant/components/awair/translations/it.json b/homeassistant/components/awair/translations/it.json index c9480ecaaa0..27ec006fb06 100644 --- a/homeassistant/components/awair/translations/it.json +++ b/homeassistant/components/awair/translations/it.json @@ -17,6 +17,13 @@ }, "description": "Inserisci nuovamente il tuo token di accesso per sviluppatori Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Token di accesso", + "email": "Email" + }, + "description": "Inserisci nuovamente il tuo token di accesso sviluppatore Awair." + }, "user": { "data": { "access_token": "Token di accesso", diff --git a/homeassistant/components/awair/translations/nl.json b/homeassistant/components/awair/translations/nl.json index 1b58405af32..ff270b6084f 100644 --- a/homeassistant/components/awair/translations/nl.json +++ b/homeassistant/components/awair/translations/nl.json @@ -17,6 +17,12 @@ }, "description": "Voer uw Awair-ontwikkelaarstoegangstoken opnieuw in." }, + "reauth_confirm": { + "data": { + "access_token": "Toegangstoken", + "email": "E-mail" + } + }, "user": { "data": { "access_token": "Toegangstoken", diff --git a/homeassistant/components/awair/translations/no.json b/homeassistant/components/awair/translations/no.json index 98486a28b09..13232ca37df 100644 --- a/homeassistant/components/awair/translations/no.json +++ b/homeassistant/components/awair/translations/no.json @@ -17,6 +17,13 @@ }, "description": "Skriv inn tilgangstokenet for Awair-utviklere p\u00e5 nytt." }, + "reauth_confirm": { + "data": { + "access_token": "Tilgangstoken", + "email": "E-post" + }, + "description": "Skriv inn Awair-utviklertilgangstokenet ditt p\u00e5 nytt." + }, "user": { "data": { "access_token": "Tilgangstoken", diff --git a/homeassistant/components/awair/translations/pt-BR.json b/homeassistant/components/awair/translations/pt-BR.json index 635a7373b75..7406bdf3ee0 100644 --- a/homeassistant/components/awair/translations/pt-BR.json +++ b/homeassistant/components/awair/translations/pt-BR.json @@ -17,6 +17,13 @@ }, "description": "Insira novamente seu token de acesso de desenvolvedor Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Token de acesso", + "email": "E-mail" + }, + "description": "Insira novamente seu token de acesso de desenvolvedor Awair." + }, "user": { "data": { "access_token": "Token de acesso", diff --git a/homeassistant/components/awair/translations/sv.json b/homeassistant/components/awair/translations/sv.json new file mode 100644 index 00000000000..1fda5b91f5a --- /dev/null +++ b/homeassistant/components/awair/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 2c5239a8fb3..f94c27dc2ac 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -139,18 +139,18 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): title = f"{model} - {serial}" return self.async_create_entry(title=title, data=self.device_config) - async def async_step_reauth(self, device_config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" self.context["title_placeholders"] = { - CONF_NAME: device_config[CONF_NAME], - CONF_HOST: device_config[CONF_HOST], + CONF_NAME: entry_data[CONF_NAME], + CONF_HOST: entry_data[CONF_HOST], } self.discovery_schema = { - vol.Required(CONF_HOST, default=device_config[CONF_HOST]): str, - vol.Required(CONF_USERNAME, default=device_config[CONF_USERNAME]): str, + vol.Required(CONF_HOST, default=entry_data[CONF_HOST]): str, + vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_PORT, default=device_config[CONF_PORT]): int, + vol.Required(CONF_PORT, default=entry_data[CONF_PORT]): int, } return await self.async_step_user() diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py index 350bad5852a..8fba3378886 100644 --- a/homeassistant/components/azure_devops/config_flow.py +++ b/homeassistant/components/azure_devops/config_flow.py @@ -1,9 +1,13 @@ """Config flow to configure the Azure DevOps integration.""" +from collections.abc import Mapping +from typing import Any + from aioazuredevops.client import DevOpsClient import aiohttp import voluptuous as vol from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN @@ -82,12 +86,12 @@ class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): return await self._show_setup_form(errors) return self._async_create_entry() - async def async_step_reauth(self, user_input): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - if user_input.get(CONF_ORG) and user_input.get(CONF_PROJECT): - self._organization = user_input[CONF_ORG] - self._project = user_input[CONF_PROJECT] - self._pat = user_input[CONF_PAT] + if entry_data.get(CONF_ORG) and entry_data.get(CONF_PROJECT): + self._organization = entry_data[CONF_ORG] + self._project = entry_data[CONF_PROJECT] + self._pat = entry_data[CONF_PAT] self.context["title_placeholders"] = { "project_url": f"{self._organization}/{self._project}", @@ -100,6 +104,7 @@ class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): return await self._show_reauth_form(errors) entry = await self.async_set_unique_id(self.unique_id) + assert entry self.hass.config_entries.async_update_entry( entry, data={ diff --git a/homeassistant/components/azure_devops/translations/bg.json b/homeassistant/components/azure_devops/translations/bg.json index 60c6d07d013..28af7ef6e00 100644 --- a/homeassistant/components/azure_devops/translations/bg.json +++ b/homeassistant/components/azure_devops/translations/bg.json @@ -12,7 +12,8 @@ "step": { "user": { "data": { - "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f" + "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f", + "project": "\u041f\u0440\u043e\u0435\u043a\u0442" } } } diff --git a/homeassistant/components/azure_devops/translations/sv.json b/homeassistant/components/azure_devops/translations/sv.json new file mode 100644 index 00000000000..e87d9570334 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "project": "Projekt" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index a0dded5f487..26980231dc1 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -79,7 +79,9 @@ class AEHConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> AEHOptionsFlowHandler: """Get the options flow for this handler.""" return AEHOptionsFlowHandler(config_entry) @@ -170,7 +172,7 @@ class AEHConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class AEHOptionsFlowHandler(config_entries.OptionsFlow): """Handle azure event hub options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize AEH options flow.""" self.config_entry = config_entry self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 32a3ea5e693..73d60fa6c03 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -40,8 +40,8 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", name="Auto Comfort Minimum Speed", - min_value=0, - max_value=SPEED_RANGE[1] - 1, + native_min_value=0, + native_max_value=SPEED_RANGE[1] - 1, entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(Optional[int], device.comfort_min_speed), mode=NumberMode.BOX, @@ -49,8 +49,8 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_max_speed", name="Auto Comfort Maximum Speed", - min_value=1, - max_value=SPEED_RANGE[1], + native_min_value=1, + native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(Optional[int], device.comfort_max_speed), mode=NumberMode.BOX, @@ -58,8 +58,8 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_heat_assist_speed", name="Auto Comfort Heat Assist Speed", - min_value=SPEED_RANGE[0], - max_value=SPEED_RANGE[1], + native_min_value=SPEED_RANGE[0], + native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(Optional[int], device.comfort_heat_assist_speed), mode=NumberMode.BOX, @@ -70,20 +70,20 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="return_to_auto_timeout", name="Return to Auto Timeout", - min_value=ONE_MIN_SECS, - max_value=HALF_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast(Optional[int], device.return_to_auto_timeout), mode=NumberMode.SLIDER, ), BAFNumberDescription( key="motion_sense_timeout", name="Motion Sense Timeout", - min_value=ONE_MIN_SECS, - max_value=ONE_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast(Optional[int], device.motion_sense_timeout), mode=NumberMode.SLIDER, ), @@ -93,10 +93,10 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", name="Light Return to Auto Timeout", - min_value=ONE_MIN_SECS, - max_value=HALF_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast( Optional[int], device.light_return_to_auto_timeout ), @@ -105,10 +105,10 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_auto_motion_timeout", name="Light Motion Sense Timeout", - min_value=ONE_MIN_SECS, - max_value=ONE_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast(Optional[int], device.light_auto_motion_timeout), mode=NumberMode.SLIDER, ), @@ -149,8 +149,8 @@ class BAFNumber(BAFEntity, NumberEntity): def _async_update_attrs(self) -> None: """Update attrs from device.""" if (value := self.entity_description.value_fn(self._device)) is not None: - self._attr_value = float(value) + self._attr_native_value = float(value) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the value.""" setattr(self._device, self.entity_description.key, int(value)) diff --git a/homeassistant/components/baf/translations/pt-BR.json b/homeassistant/components/baf/translations/pt-BR.json index 72ce0dd06fc..5c55dcd80d8 100644 --- a/homeassistant/components/baf/translations/pt-BR.json +++ b/homeassistant/components/baf/translations/pt-BR.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "ipv6_not_supported": "IPv6 n\u00e3o \u00e9 suportado." }, "error": { - "cannot_connect": "Falhou ao se conectar", + "cannot_connect": "Falha ao conectar", "unknown": "Erro inesperado" }, "flow_title": "{name} - {model} ({ip_address})", diff --git a/homeassistant/components/baf/translations/sv.json b/homeassistant/components/baf/translations/sv.json new file mode 100644 index 00000000000..e3270d4036a --- /dev/null +++ b/homeassistant/components/baf/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ca.json b/homeassistant/components/binary_sensor/translations/ca.json index 10d705e797b..a65633140a0 100644 --- a/homeassistant/components/binary_sensor/translations/ca.json +++ b/homeassistant/components/binary_sensor/translations/ca.json @@ -81,7 +81,7 @@ "not_moist": "{entity_name} es torna sec", "not_moving": "{entity_name} ha parat de moure's", "not_occupied": "{entity_name} es desocupa", - "not_opened": "{entity_name} es tanca", + "not_opened": "{entity_name} tancat/tancada", "not_plugged_in": "{entity_name} desendollat", "not_powered": "{entity_name} no est\u00e0 alimentat", "not_present": "{entity_name} no est\u00e0 present", diff --git a/homeassistant/components/binary_sensor/translations/sv.json b/homeassistant/components/binary_sensor/translations/sv.json index c6685403e24..904ecd8fddc 100644 --- a/homeassistant/components/binary_sensor/translations/sv.json +++ b/homeassistant/components/binary_sensor/translations/sv.json @@ -36,9 +36,10 @@ "is_on": "{entity_name} \u00e4r p\u00e5", "is_open": "{entity_name} \u00e4r \u00f6ppen", "is_plugged_in": "{entity_name} \u00e4r ansluten", - "is_powered": "{entity_name} \u00e4r str\u00f6mf\u00f6rd", + "is_powered": "{entity_name} \u00e4r p\u00e5slagen", "is_present": "{entity_name} \u00e4r n\u00e4rvarande", "is_problem": "{entity_name} uppt\u00e4cker problem", + "is_running": "{entity_name} k\u00f6rs", "is_smoke": "{entity_name} detekterar r\u00f6k", "is_sound": "{entity_name} uppt\u00e4cker ljud", "is_unsafe": "{entity_name} \u00e4r os\u00e4ker", @@ -72,13 +73,13 @@ "not_occupied": "{entity_name} blev inte upptagen", "not_opened": "{entity_name} st\u00e4ngd", "not_plugged_in": "{entity_name} urkopplad", - "not_powered": "{entity_name} inte str\u00f6mf\u00f6rd", + "not_powered": "{entity_name} inte p\u00e5slagen", "not_present": "{entity_name} inte n\u00e4rvarande", "not_unsafe": "{entity_name} blev s\u00e4ker", "occupied": "{entity_name} blev upptagen", "opened": "{entity_name} \u00f6ppnades", "plugged_in": "{entity_name} ansluten", - "powered": "{entity_name} str\u00f6mf\u00f6rd", + "powered": "{entity_name} p\u00e5slagen", "present": "{entity_name} n\u00e4rvarande", "problem": "{entity_name} b\u00f6rjade uppt\u00e4cka problem", "smoke": "{entity_name} b\u00f6rjade detektera r\u00f6k", @@ -90,7 +91,9 @@ } }, "device_class": { - "motion": "r\u00f6relse" + "heat": "v\u00e4rme", + "motion": "r\u00f6relse", + "power": "effekt" }, "state": { "_": { @@ -118,7 +121,7 @@ "on": "\u00d6ppen" }, "gas": { - "off": "Klart", + "off": "Rensa", "on": "Detekterad" }, "heat": { @@ -166,7 +169,7 @@ "on": "Detekterad" }, "vibration": { - "off": "Klart", + "off": "Rensa", "on": "Detekterad" }, "window": { diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index b6a0045940d..a8f93dd0122 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -1,8 +1,8 @@ """The BleBox devices integration.""" import logging +from blebox_uniapi.box import Box from blebox_uniapi.error import Error -from blebox_uniapi.products import Products from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry @@ -30,7 +30,6 @@ PARALLEL_UPDATES = 0 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BleBox devices from a config entry.""" - websession = async_get_clientsession(hass) host = entry.data[CONF_HOST] @@ -40,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_host = ApiHost(host, port, timeout, websession, hass.loop) try: - product = await Products.async_from_host(api_host) + product = await Box.async_from_host(api_host) except Error as ex: _LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex) raise ConfigEntryNotReady from ex @@ -50,7 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: product = domain_entry.setdefault(PRODUCT, product) hass.config_entries.async_setup_platforms(entry, PLATFORMS) - return True @@ -71,8 +69,8 @@ def create_blebox_entities( """Create entities from a BleBox product's features.""" product = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] - entities = [] + if entity_type in product.features: for feature in product.features[entity_type]: entities.append(entity_klass(feature)) diff --git a/homeassistant/components/blebox/air_quality.py b/homeassistant/components/blebox/air_quality.py index 31efd797678..daadbc831b6 100644 --- a/homeassistant/components/blebox/air_quality.py +++ b/homeassistant/components/blebox/air_quality.py @@ -1,4 +1,6 @@ """BleBox air quality entity.""" +from datetime import timedelta + from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -6,6 +8,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BleBoxEntity, create_blebox_entities +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 20a019ec0ec..e279991df20 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -1,4 +1,6 @@ """BleBox climate entity.""" +from datetime import timedelta + from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( ClimateEntityFeature, @@ -12,6 +14,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BleBoxEntity, create_blebox_entities +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 17dffe154d1..5ae975f83d9 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -1,8 +1,8 @@ """Config flow for BleBox devices integration.""" import logging +from blebox_uniapi.box import Box from blebox_uniapi.error import Error, UnsupportedBoxVersion -from blebox_uniapi.products import Products from blebox_uniapi.session import ApiHost import voluptuous as vol @@ -65,7 +65,6 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, step, exception, schema, host, port, message_id, log_fn ): """Handle step exceptions.""" - log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception) return self.async_show_form( @@ -101,9 +100,8 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): websession = async_get_clientsession(hass) api_host = ApiHost(*addr, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER) - try: - product = await Products.async_from_host(api_host) + product = await Box.async_from_host(api_host) except UnsupportedBoxVersion as ex: return self.handle_step_exception( diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 09d28a4f87a..368443988a4 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -1,4 +1,8 @@ """BleBox cover entity.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, CoverEntity, @@ -39,7 +43,7 @@ class BleBoxCoverEntity(BleBoxEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current cover position.""" position = self._feature.current if position == -1: # possible for shutterBox @@ -48,38 +52,38 @@ class BleBoxCoverEntity(BleBoxEntity, CoverEntity): return None if position is None else 100 - position @property - def is_opening(self): + def is_opening(self) -> bool | None: """Return whether cover is opening.""" return self._is_state(STATE_OPENING) @property - def is_closing(self): + def is_closing(self) -> bool | None: """Return whether cover is closing.""" return self._is_state(STATE_CLOSING) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return whether cover is closed.""" return self._is_state(STATE_CLOSED) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover position.""" await self._feature.async_open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover position.""" await self._feature.async_close() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover position.""" position = kwargs[ATTR_POSITION] await self._feature.async_set_position(100 - position) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._feature.async_stop() - def _is_state(self, state_name): + def _is_state(self, state_name) -> bool | None: value = BLEBOX_TO_HASS_COVER_STATES[self._feature.state] return None if value is None else value == state_name diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index a582fe133c1..32cc6360db1 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -1,23 +1,34 @@ """BleBox light entities implementation.""" +from __future__ import annotations + +from datetime import timedelta import logging from blebox_uniapi.error import BadOnValueError +import blebox_uniapi.light +from blebox_uniapi.light import BleboxColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_RGB_COLOR, ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ColorMode, LightEntity, + LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import color_rgb_to_hex, rgb_hex_to_rgb_list from . import BleBoxEntity, create_blebox_entities _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, @@ -31,6 +42,17 @@ async def async_setup_entry( ) +COLOR_MODE_MAP = { + BleboxColorMode.RGBW: ColorMode.RGBW, + BleboxColorMode.RGB: ColorMode.RGB, + BleboxColorMode.MONO: ColorMode.BRIGHTNESS, + BleboxColorMode.RGBorW: ColorMode.RGBW, # white hex is prioritised over RGB channel + BleboxColorMode.CT: ColorMode.COLOR_TEMP, + BleboxColorMode.CTx2: ColorMode.COLOR_TEMP, # two instances + BleboxColorMode.RGBWW: ColorMode.RGBWW, +} + + class BleBoxLightEntity(BleBoxEntity, LightEntity): """Representation of BleBox lights.""" @@ -38,6 +60,7 @@ class BleBoxLightEntity(BleBoxEntity, LightEntity): """Initialize a BleBox light.""" super().__init__(feature) self._attr_supported_color_modes = {self.color_mode} + self._attr_supported_features = LightEntityFeature.EFFECT @property def is_on(self) -> bool: @@ -49,46 +72,105 @@ class BleBoxLightEntity(BleBoxEntity, LightEntity): """Return the name.""" return self._feature.brightness + @property + def color_temp(self): + """Return color temperature.""" + return self._feature.color_temp + @property def color_mode(self): - """Return the color mode.""" - if self._feature.supports_white and self._feature.supports_color: - return ColorMode.RGBW - if self._feature.supports_brightness: - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF + """Return the color mode. + + Set values to _attr_ibutes if needed. + """ + color_mode_tmp = COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF) + if color_mode_tmp == ColorMode.COLOR_TEMP: + self._attr_min_mireds = 1 + self._attr_max_mireds = 255 + + return color_mode_tmp + + @property + def effect_list(self) -> list[str] | None: + """Return the list of supported effects.""" + return self._feature.effect_list + + @property + def effect(self) -> str | None: + """Return the current effect.""" + return self._feature.effect + + @property + def rgb_color(self): + """Return value for rgb.""" + if (rgb_hex := self._feature.rgb_hex) is None: + return None + return tuple( + blebox_uniapi.light.Light.normalise_elements_of_rgb( + blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgb_hex)[0:3] + ) + ) @property def rgbw_color(self): """Return the hue and saturation.""" if (rgbw_hex := self._feature.rgbw_hex) is None: return None + return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbw_hex)[0:4]) - return tuple(rgb_hex_to_rgb_list(rgbw_hex)[0:4]) + @property + def rgbww_color(self): + """Return value for rgbww.""" + if (rgbww_hex := self._feature.rgbww_hex) is None: + return None + return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex)) async def async_turn_on(self, **kwargs): """Turn the light on.""" rgbw = kwargs.get(ATTR_RGBW_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) - + effect = kwargs.get(ATTR_EFFECT) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + rgbww = kwargs.get(ATTR_RGBWW_COLOR) feature = self._feature value = feature.sensible_on_value - - if brightness is not None: - value = feature.apply_brightness(value, brightness) + rgb = kwargs.get(ATTR_RGB_COLOR) if rgbw is not None: - value = feature.apply_white(value, rgbw[3]) - value = feature.apply_color(value, color_rgb_to_hex(*rgbw[0:3])) - - try: - await self._feature.async_on(value) - except BadOnValueError as ex: - _LOGGER.error( - "Turning on '%s' failed: Bad value %s (%s)", self.name, value, ex + value = list(rgbw) + if color_temp is not None: + value = feature.return_color_temp_with_brightness( + int(color_temp), self.brightness ) + if rgbww is not None: + value = list(rgbww) + + if rgb is not None: + if self.color_mode == ColorMode.RGB and brightness is None: + brightness = self.brightness + value = list(rgb) + + if brightness is not None: + if self.color_mode == ATTR_COLOR_TEMP: + value = feature.return_color_temp_with_brightness( + self.color_temp, brightness + ) + else: + value = feature.apply_brightness(value, brightness) + + if effect is not None: + effect_value = self.effect_list.index(effect) + await self._feature.async_api_command("effect", effect_value) + else: + try: + await self._feature.async_on(value) + except BadOnValueError as ex: + _LOGGER.error( + "Turning on '%s' failed: Bad value %s (%s)", self.name, value, ex + ) + async def async_turn_off(self, **kwargs): """Turn the light off.""" await self._feature.async_off() diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index d9c0481fff6..5c57d5f6b9f 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -3,8 +3,8 @@ "name": "BleBox devices", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", - "requirements": ["blebox_uniapi==1.3.3"], - "codeowners": ["@bbx-a", "@bbx-jp"], + "requirements": ["blebox_uniapi==2.0.0"], + "codeowners": ["@bbx-a", "@bbx-jp", "@riokuu"], "iot_class": "local_polling", "loggers": ["blebox_uniapi"] } diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index 9586b37558f..50eba1d2c4a 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -1,4 +1,6 @@ """BleBox switch implementation.""" +from datetime import timedelta + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -7,6 +9,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BleBoxEntity, create_blebox_entities from .const import BLEBOX_TO_HASS_DEVICE_CLASSES +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index dbea1371af2..22a142ff44c 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Blink Alarm Control Panel.""" +from __future__ import annotations + import logging from homeassistant.components.alarm_control_panel import ( @@ -51,7 +53,7 @@ class BlinkSyncModule(AlarmControlPanelEntity): identifiers={(DOMAIN, sync.serial)}, name=name, manufacturer=DEFAULT_BRAND ) - def update(self): + def update(self) -> None: """Update the state of the device.""" _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) self.data.refresh() @@ -63,12 +65,12 @@ class BlinkSyncModule(AlarmControlPanelEntity): self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self.sync.arm = False self.sync.refresh() - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" self.sync.arm = True self.sync.refresh() diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index a4bee490fb3..4f1c1997cad 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -1,5 +1,9 @@ """Config flow to configure Blink.""" +from __future__ import annotations + +from collections.abc import Mapping import logging +from typing import Any from blinkpy.auth import Auth, LoginError, TokenRefreshFailed from blinkpy.blinkpy import Blink, BlinkSetupError @@ -13,6 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN @@ -49,7 +54,9 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> BlinkOptionsFlowHandler: """Get options flow for this handler.""" return BlinkOptionsFlowHandler(config_entry) @@ -116,9 +123,9 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, entry_data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" - return await self.async_step_user(entry_data) + return await self.async_step_user(dict(entry_data)) @callback def _async_finish_flow(self): @@ -129,7 +136,7 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class BlinkOptionsFlowHandler(config_entries.OptionsFlow): """Handle Blink options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Blink options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/blink/translations/sv.json b/homeassistant/components/blink/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/blink/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index bfefff36601..d1b86f80326 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -2,7 +2,7 @@ "domain": "bluesound", "name": "Bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": ["@thrawnarn"], "iot_class": "local_polling" } diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 9cec9a73ce7..baa7870ee8c 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -131,4 +131,4 @@ class BMWButton(BMWBaseEntity, ButtonEntity): # Always update HA states after a button was executed. # BMW remote services that change the vehicle's state update the local object # when executing the service, so only the HA state machine needs further updates. - self.coordinator.notify_listeners() + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index c07be4c8849..3994b0732a8 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from bimmer_connected.account import MyBMWAccount +from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name from httpx import HTTPError import voluptuous as vol @@ -14,7 +14,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY +from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_REFRESH_TOKEN DATA_SCHEMA = vol.Schema( { @@ -32,19 +32,22 @@ async def validate_input( Data has the keys from DATA_SCHEMA with values provided by the user. """ - account = MyBMWAccount( + auth = MyBMWAuthentication( data[CONF_USERNAME], data[CONF_PASSWORD], get_region_from_name(data[CONF_REGION]), ) try: - await account.get_vehicles() + await auth.login() except HTTPError as ex: raise CannotConnect from ex # Return info that you want to store in the config entry. - return {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} + retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} + if auth.refresh_token: + retval[CONF_REFRESH_TOKEN] = auth.refresh_token + return retval class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -70,7 +73,13 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" if info: - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=info["title"], + data={ + **user_input, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + }, + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 47d1f358686..e5a968b47fd 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -7,7 +7,7 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name from bimmer_connected.models import GPSPosition -from httpx import HTTPError, TimeoutException +from httpx import HTTPError, HTTPStatusError, TimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME @@ -16,7 +16,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN -SCAN_INTERVAL = timedelta(seconds=300) +DEFAULT_SCAN_INTERVAL_SECONDS = 300 +SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) _LOGGER = logging.getLogger(__name__) @@ -53,8 +54,18 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): try: await self.account.get_vehicles() - except (HTTPError, TimeoutException) as err: - self._update_config_entry_refresh_token(None) + except (HTTPError, HTTPStatusError, TimeoutException) as err: + if isinstance(err, HTTPStatusError) and err.response.status_code == 429: + # Increase scan interval to not jump to not bring up the issue next time + self.update_interval = timedelta( + seconds=DEFAULT_SCAN_INTERVAL_SECONDS * 3 + ) + if isinstance(err, HTTPStatusError) and err.response.status_code in ( + 401, + 403, + ): + # Clear refresh token only on issues with authorization + self._update_config_entry_refresh_token(None) raise UpdateFailed(f"Error communicating with BMW API: {err}") from err if self.account.refresh_token != old_refresh_token: @@ -65,6 +76,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): self.account.refresh_token, ) + # Reset scan interval after successful update + self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) + def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" data = { @@ -74,8 +88,3 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): if not refresh_token: data.pop(CONF_REFRESH_TOKEN) self.hass.config_entries.async_update_entry(self._entry, data=data) - - def notify_listeners(self) -> None: - """Notify all listeners to refresh HA state machine.""" - for update_callback in self._listeners: - update_callback() diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cd6daa83705..b10d4842163 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.4"], + "requirements": ["bimmer_connected==0.9.6"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 557e68272c2..7dca4db507d 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -20,7 +20,8 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import SLOW_UPDATE_WARNING -from .const import BPUP_SUBS, BRIDGE_MAKE, DOMAIN, HUB +from .const import BRIDGE_MAKE, DOMAIN +from .models import BondData from .utils import BondHub PLATFORMS = [ @@ -69,11 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_stop_event) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - HUB: hub, - BPUP_SUBS: bpup_subs, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BondData(hub, bpup_subs) if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=hub.bond_id) @@ -102,8 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -119,3 +115,25 @@ def _async_remove_old_device_identifiers( continue if config_entry_id in dev.config_entries: device_registry.async_remove_device(dev.id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove bond config entry from a device.""" + data: BondData = hass.data[DOMAIN][config_entry.entry_id] + hub = data.hub + for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN or len(identifier) != 3: + continue + bond_id: str = identifier[1] + # Bond still uses the 3 arg tuple before + # the identifiers were typed + device_id: str = identifier[2] # type: ignore[misc] + # If device_id is no longer present on + # the hub, we allow removal. + if hub.bond_id != bond_id or not any( + device_id == device.device_id for device in hub.devices + ): + return True + return False diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 0465e4c51fe..9a82309e347 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -2,8 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -import logging -from typing import Any from bond_async import Action, BPUPSubscriptions @@ -12,12 +10,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import DOMAIN from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub -_LOGGER = logging.getLogger(__name__) - # The api requires a step size even though it does not # seem to matter what is is as the underlying device is likely # getting an increase/decrease signal only @@ -246,9 +243,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond button devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs entities: list[BondButtonEntity] = [] for device in hub.devices: @@ -287,12 +284,12 @@ class BondButtonEntity(BondEntity, ButtonEntity): description: BondButtonEntityDescription, ) -> None: """Init Bond button.""" + self.entity_description = description super().__init__( hub, device, bpup_subs, description.name, description.key.lower() ) - self.entity_description = description - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" if self.entity_description.argument: action = Action( @@ -302,5 +299,5 @@ class BondButtonEntity(BondEntity, ButtonEntity): action = Action(self.entity_description.key) await self._hub.bond.action(self._device.device_id, action) - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: """Apply the state.""" diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 6eba9897468..09386c3587d 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Bond integration.""" from __future__ import annotations +import contextlib from http import HTTPStatus import logging from typing import Any @@ -33,10 +34,9 @@ TOKEN_SCHEMA = vol.Schema({}) async def async_get_token(hass: HomeAssistant, host: str) -> str | None: """Try to fetch the token from the bond device.""" bond = Bond(host, "", session=async_get_clientsession(hass)) - try: - response: dict[str, str] = await bond.token() - except ClientConnectionError: - return None + response: dict[str, str] = {} + with contextlib.suppress(ClientConnectionError): + response = await bond.token() return response.get("token") @@ -101,6 +101,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) + hass = self.hass for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue @@ -110,13 +111,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): updates[CONF_ACCESS_TOKEN] = token new_data = {**entry.data, **updates} - if new_data != dict(entry.data): - self.hass.config_entries.async_update_entry( - entry, data={**entry.data, **updates} - ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) + changed = new_data != dict(entry.data) + if changed: + hass.config_entries.async_update_entry(entry, data=new_data) + entry_id = entry.entry_id + hass.async_create_task(hass.config_entries.async_reload(entry_id)) raise AbortFlow("already_configured") self._discovered = {CONF_HOST: host, CONF_NAME: bond_id} diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 778dcbc1a1f..91197763d23 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -7,9 +7,6 @@ DOMAIN = "bond" CONF_BOND_ID: str = "bond_id" -HUB = "hub" -BPUP_SUBS = "bpup_subs" - SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state" SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state" SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state" diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 3938de0d4bd..0a3e9048451 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -13,11 +13,11 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import DOMAIN from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub @@ -37,17 +37,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond cover devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs - covers: list[Entity] = [ + async_add_entities( BondCover(hub, device, bpup_subs) for device in hub.devices if device.type == DeviceType.MOTORIZED_SHADES - ] - - async_add_entities(covers, True) + ) class BondCover(BondEntity, CoverEntity): @@ -78,7 +76,8 @@ class BondCover(BondEntity, CoverEntity): supported_features |= CoverEntityFeature.STOP_TILT self._attr_supported_features = supported_features - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state cover_open = state.get("open") self._attr_is_closed = None if cover_open is None else cover_open == 0 if (bond_position := state.get("position")) is not None: diff --git a/homeassistant/components/bond/diagnostics.py b/homeassistant/components/bond/diagnostics.py index 6af62c3fb24..53e8b5c8225 100644 --- a/homeassistant/components/bond/diagnostics.py +++ b/homeassistant/components/bond/diagnostics.py @@ -7,8 +7,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, HUB -from .utils import BondHub +from .const import DOMAIN +from .models import BondData TO_REDACT = {"access_token"} @@ -17,8 +17,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub return { "entry": { "title": entry.title, diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 832e9b5d464..f9f09cfe3cb 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -64,6 +64,8 @@ class BondEntity(Entity): self._attr_name = f"{device.name} {sub_device_name}" else: self._attr_name = device.name + self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state + self._apply_state() @property def device_info(self) -> DeviceInfo: @@ -137,10 +139,9 @@ class BondEntity(Entity): self._attr_available = False else: self._async_state_callback(state) - self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state @abstractmethod - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: raise NotImplementedError @callback @@ -153,7 +154,8 @@ class BondEntity(Entity): _LOGGER.debug( "Device state for %s (%s) is:\n%s", self.name, self.entity_id, state ) - self._apply_state(state) + self._device.state = state + self._apply_state() @callback def _async_bpup_callback(self, json_msg: dict) -> None: diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index f2f6b15f923..d1121e4a3a8 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -19,7 +19,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, @@ -27,8 +26,9 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_FAN_SPEED_TRACKED_STATE +from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) @@ -42,24 +42,21 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond fan devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs platform = entity_platform.async_get_current_platform() - - fans: list[Entity] = [ - BondFan(hub, device, bpup_subs) - for device in hub.devices - if DeviceType.is_fan(device.type) - ] - platform.async_register_entity_service( SERVICE_SET_FAN_SPEED_TRACKED_STATE, {vol.Required("speed"): vol.All(vol.Number(scale=0), vol.Range(0, 100))}, "async_set_speed_belief", ) - async_add_entities(fans, True) + async_add_entities( + BondFan(hub, device, bpup_subs) + for device in hub.devices + if DeviceType.is_fan(device.type) + ) class BondFan(BondEntity, FanEntity): @@ -69,15 +66,15 @@ class BondFan(BondEntity, FanEntity): self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions ) -> None: """Create HA entity representing Bond fan.""" - super().__init__(hub, device, bpup_subs) - self._power: bool | None = None self._speed: int | None = None self._direction: int | None = None + super().__init__(hub, device, bpup_subs) if self._device.has_action(Action.BREEZE_ON): self._attr_preset_modes = [PRESET_MODE_BREEZE] - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._power = state.get("power") self._speed = state.get("speed") self._direction = state.get("direction") diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 55084f37b03..2fcff44ddc1 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -18,13 +18,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_POWER_STATE, - BPUP_SUBS, DOMAIN, - HUB, SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, SERVICE_SET_LIGHT_POWER_TRACKED_STATE, ) from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) @@ -46,9 +45,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond light devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform() @@ -115,7 +114,6 @@ async def async_setup_entry( async_add_entities( fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights, - True, ) @@ -170,7 +168,8 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._attr_is_on = state.get("light") == 1 brightness = state.get("brightness") self._attr_brightness = round(brightness * 255 / 100) if brightness else None @@ -227,7 +226,8 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): class BondDownLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._attr_is_on = bool(state.get("down_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: @@ -246,7 +246,8 @@ class BondDownLight(BondBaseLight, BondEntity, LightEntity): class BondUpLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._attr_is_on = bool(state.get("up_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: @@ -268,7 +269,8 @@ class BondFireplace(BondEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state power = state.get("power") flame = state.get("flame") self._attr_is_on = power == 1 diff --git a/homeassistant/components/bond/models.py b/homeassistant/components/bond/models.py new file mode 100644 index 00000000000..0caa01af7a0 --- /dev/null +++ b/homeassistant/components/bond/models.py @@ -0,0 +1,16 @@ +"""The bond integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from bond_async import BPUPSubscriptions + +from .utils import BondHub + + +@dataclass +class BondData: + """Data for the bond integration.""" + + hub: BondHub + bpup_subs: BPUPSubscriptions diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index da0b19dd9ff..afa5e1cee10 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_async import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, DeviceType import voluptuous as vol from homeassistant.components.switch import SwitchEntity @@ -12,18 +12,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_POWER_STATE, - BPUP_SUBS, - DOMAIN, - HUB, - SERVICE_SET_POWER_TRACKED_STATE, -) +from .const import ATTR_POWER_STATE, DOMAIN, SERVICE_SET_POWER_TRACKED_STATE from .entity import BondEntity -from .utils import BondHub +from .models import BondData async def async_setup_entry( @@ -32,31 +25,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond generic devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs platform = entity_platform.async_get_current_platform() - - switches: list[Entity] = [ - BondSwitch(hub, device, bpup_subs) - for device in hub.devices - if DeviceType.is_generic(device.type) - ] - platform.async_register_entity_service( SERVICE_SET_POWER_TRACKED_STATE, {vol.Required(ATTR_POWER_STATE): cv.boolean}, "async_set_power_belief", ) - async_add_entities(switches, True) + async_add_entities( + BondSwitch(hub, device, bpup_subs) + for device in hub.devices + if DeviceType.is_generic(device.type) + ) class BondSwitch(BondEntity, SwitchEntity): """Representation of a Bond generic device.""" - def _apply_state(self, state: dict) -> None: - self._attr_is_on = state.get("power") == 1 + def _apply_state(self) -> None: + self._attr_is_on = self._device.state.get("power") == 1 async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/components/bond/translations/sv.json b/homeassistant/components/bond/translations/sv.json new file mode 100644 index 00000000000..1fda5b91f5a --- /dev/null +++ b/homeassistant/components/bond/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index c426bf64577..3a161a74bc5 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -20,11 +20,16 @@ class BondDevice: """Helper device class to hold ID and attributes together.""" def __init__( - self, device_id: str, attrs: dict[str, Any], props: dict[str, Any] + self, + device_id: str, + attrs: dict[str, Any], + props: dict[str, Any], + state: dict[str, Any], ) -> None: """Create a helper device from ID and attributes returned by API.""" self.device_id = device_id self.props = props + self.state = state self._attrs = attrs or {} self._supported_actions: set[str] = set(self._attrs.get("actions", [])) @@ -34,6 +39,7 @@ class BondDevice: "device_id": self.device_id, "props": self.props, "attrs": self._attrs, + "state": self.state, }.__repr__() @property @@ -150,7 +156,11 @@ class BondHub: break setup_device_ids.append(device_id) tasks.extend( - [self.bond.device(device_id), self.bond.device_properties(device_id)] + [ + self.bond.device(device_id), + self.bond.device_properties(device_id), + self.bond.device_state(device_id), + ] ) responses = await gather_with_concurrency(MAX_REQUESTS, *tasks) @@ -158,10 +168,13 @@ class BondHub: for device_id in setup_device_ids: self._devices.append( BondDevice( - device_id, responses[response_idx], responses[response_idx + 1] + device_id, + responses[response_idx], + responses[response_idx + 1], + responses[response_idx + 2], ) ) - response_idx += 2 + response_idx += 3 _LOGGER.debug("Discovered Bond devices: %s", self._devices) try: diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 2b95702e44c..4d076a784d1 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -19,7 +19,12 @@ from .const import ( DOMAIN, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.COVER, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 6ad1a374a5a..7cc4527a64f 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -1,6 +1,8 @@ """Config flow for Bosch Smart Home Controller integration.""" +from collections.abc import Mapping import logging from os import makedirs +from typing import Any from boschshcpy import SHCRegisterClient, SHCSession from boschshcpy.exceptions import ( @@ -83,7 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = None hostname = None - async def async_step_reauth(self, user_input=None): + 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() diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index fd191d59bc3..91dc361a23d 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -1,4 +1,6 @@ """Platform for cover integration.""" +from typing import Any + from boschshcpy import SHCSession, SHCShutterControl from homeassistant.components.cover import ( @@ -50,21 +52,21 @@ class ShutterControlCover(SHCEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current cover position.""" return round(self._device.level * 100.0) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._device.stop() @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed or not.""" return self.current_cover_position == 0 @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return ( self._device.operation_state @@ -72,22 +74,22 @@ class ShutterControlCover(SHCEntity, CoverEntity): ) @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return ( self._device.operation_state == SHCShutterControl.ShutterControlService.State.CLOSING ) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._device.level = 1.0 - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close cover.""" self._device.level = 0.0 - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] self._device.level = position / 100.0 diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index c3a981aa658..4ef0e37132d 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -1,5 +1,7 @@ """Bosch Smart Home Controller base entity.""" -from boschshcpy.device import SHCDevice +from __future__ import annotations + +from boschshcpy import SHCDevice, SHCIntrusionSystem from homeassistant.helpers.device_registry import async_get as get_dev_reg from homeassistant.helpers.entity import DeviceInfo, Entity @@ -7,7 +9,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN -async def async_remove_devices(hass, entity, entry_id): +async def async_remove_devices(hass, entity, entry_id) -> None: """Get item that is removed from session.""" dev_registry = get_dev_reg(hass) device = dev_registry.async_get_device( @@ -17,16 +19,42 @@ async def async_remove_devices(hass, entity, entry_id): dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id) -class SHCEntity(Entity): - """Representation of a SHC base entity.""" +class SHCBaseEntity(Entity): + """Base representation of a SHC entity.""" _attr_should_poll = False - def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + def __init__( + self, device: SHCDevice | SHCIntrusionSystem, parent_id: str, entry_id: str + ) -> None: """Initialize the generic SHC device.""" self._device = device self._entry_id = entry_id self._attr_name = device.name + + async def async_added_to_hass(self) -> None: + """Subscribe to SHC events.""" + await super().async_added_to_hass() + + def on_state_changed() -> None: + if self._device.deleted: + self.hass.add_job(async_remove_devices(self.hass, self, self._entry_id)) + else: + self.schedule_update_ha_state() + + self._device.subscribe_callback(self.entity_id, on_state_changed) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from SHC events.""" + await super().async_will_remove_from_hass() + self._device.unsubscribe_callback(self.entity_id) + + +class SHCEntity(SHCBaseEntity): + """Representation of a SHC device entity.""" + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize generic SHC device.""" self._attr_unique_id = device.serial self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.id)}, @@ -40,32 +68,51 @@ class SHCEntity(Entity): else parent_id, ), ) + super().__init__(device=device, parent_id=parent_id, entry_id=entry_id) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to SHC events.""" await super().async_added_to_hass() - def on_state_changed(): + def on_state_changed() -> None: self.schedule_update_ha_state() - def update_entity_information(): - if self._device.deleted: - self.hass.add_job(async_remove_devices(self.hass, self, self._entry_id)) - else: - self.schedule_update_ha_state() - for service in self._device.device_services: service.subscribe_callback(self.entity_id, on_state_changed) - self._device.subscribe_callback(self.entity_id, update_entity_information) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe from SHC events.""" await super().async_will_remove_from_hass() for service in self._device.device_services: service.unsubscribe_callback(self.entity_id) - self._device.unsubscribe_callback(self.entity_id) @property - def available(self): + def available(self) -> bool: """Return false if status is unavailable.""" return self._device.status == "AVAILABLE" + + +class SHCDomainEntity(SHCBaseEntity): + """Representation of a SHC domain service entity.""" + + def __init__( + self, domain: SHCIntrusionSystem, parent_id: str, entry_id: str + ) -> None: + """Initialize the generic SHC device.""" + self._attr_unique_id = domain.id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, domain.id)}, + manufacturer=domain.manufacturer, + model=domain.device_model, + name=domain.name, + via_device=( + DOMAIN, + parent_id, + ), + ) + super().__init__(device=domain, parent_id=parent_id, entry_id=entry_id) + + @property + def available(self) -> bool: + """Return false if status is unavailable.""" + return self._device.system_availability diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index da8b489a98b..5a0ed45b2ba 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -1,8 +1,10 @@ """Config flow for Broadlink devices.""" +from collections.abc import Mapping import errno from functools import partial import logging import socket +from typing import Any import broadlink as blk from broadlink.exceptions import ( @@ -12,9 +14,10 @@ from broadlink.exceptions import ( ) import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN @@ -40,7 +43,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "an issue at https://github.com/home-assistant/core/issues", hex(device.devtype), ) - raise data_entry_flow.AbortFlow("not_supported") + raise AbortFlow("not_supported") await self.async_set_unique_id( device.mac.hex(), raise_on_progress=raise_on_progress @@ -53,9 +56,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "host": device.host[0], } - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" host = discovery_info.ip unique_id = discovery_info.macaddress.lower().replace(":", "") @@ -188,9 +189,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(device.mac.hex()) _LOGGER.error( - "Failed to authenticate to the device at %s: %s", - device.host[0], - err_msg, # pylint: disable=used-before-assignment + "Failed to authenticate to the device at %s: %s", device.host[0], err_msg ) return self.async_show_form(step_id="auth", errors=errors) @@ -253,9 +252,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_finish() _LOGGER.error( - "Failed to unlock the device at %s: %s", - device.host[0], - err_msg, # pylint: disable=used-before-assignment + "Failed to unlock the device at %s: %s", device.host[0], err_msg ) else: @@ -304,14 +301,14 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]}) return await self.async_step_user(import_info) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Reauthenticate to the device.""" device = blk.gendevice( - data[CONF_TYPE], - (data[CONF_HOST], DEFAULT_PORT), - bytes.fromhex(data[CONF_MAC]), - name=data[CONF_NAME], + entry_data[CONF_TYPE], + (entry_data[CONF_HOST], DEFAULT_PORT), + bytes.fromhex(entry_data[CONF_MAC]), + name=entry_data[CONF_NAME], ) - device.timeout = data[CONF_TIMEOUT] + device.timeout = entry_data[CONF_TIMEOUT] await self.async_set_device(device) return await self.async_step_reset() diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 949f8add20b..4ae0e39cf04 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -18,6 +18,9 @@ }, { "macaddress": "B4430D*" + }, + { + "macaddress": "C8F742*" } ], "iot_class": "local_polling", diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index a136c98bf91..bcedc65d7ff 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -42,7 +42,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self.brother: Brother = None + self.brother: Brother self.host: str | None = None async def async_step_user( diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index aaf1af72db9..e14079f6dd9 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -3,7 +3,7 @@ "name": "Brother Printer", "documentation": "https://www.home-assistant.io/integrations/brother", "codeowners": ["@bieniu"], - "requirements": ["brother==1.1.0"], + "requirements": ["brother==1.2.3"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py index c81eb2de6ca..cfd3bfa69cb 100644 --- a/homeassistant/components/brunt/config_flow.py +++ b/homeassistant/components/brunt/config_flow.py @@ -1,6 +1,7 @@ """Config flow for brunt integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -77,9 +78,7 @@ class BruntConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + 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"] diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index c38dfa0eb78..489229622b2 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -1,7 +1,6 @@ """Support for Brunt Blind Engine covers.""" from __future__ import annotations -from collections.abc import MutableMapping from typing import Any from aiohttp.client_exceptions import ClientResponseError @@ -140,7 +139,7 @@ class BruntDevice(CoordinatorEntity, CoverEntity): return self.move_state == 2 @property - def extra_state_attributes(self) -> MutableMapping[str, Any]: + def extra_state_attributes(self) -> dict[str, Any]: """Return the detailed device state attributes.""" return { ATTR_REQUEST_POSITION: self.request_cover_position, diff --git a/homeassistant/components/brunt/translations/sv.json b/homeassistant/components/brunt/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/brunt/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/es.json b/homeassistant/components/bsblan/translations/es.json index 830752dd863..6e533b5916b 100644 --- a/homeassistant/components/bsblan/translations/es.json +++ b/homeassistant/components/bsblan/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "Fallo en la conexi\u00f3n" }, "error": { "cannot_connect": "No se pudo conectar" diff --git a/homeassistant/components/bsblan/translations/sv.json b/homeassistant/components/bsblan/translations/sv.json index 46631acc69a..6dad0946ee5 100644 --- a/homeassistant/components/bsblan/translations/sv.json +++ b/homeassistant/components/bsblan/translations/sv.json @@ -2,6 +2,14 @@ "config": { "abort": { "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "port": "Port", + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index aa336d3929c..6fdf5c166ee 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -28,16 +28,25 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + LENGTH_METERS, + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -111,7 +120,11 @@ async def async_setup_entry( class BrWeather(WeatherEntity): """Representation of a weather condition.""" - _attr_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_METERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__(self, data, config, coordinates): """Initialize the platform with a data instance and station name.""" @@ -142,12 +155,12 @@ class BrWeather(WeatherEntity): return conditions.get(ccode) @property - def temperature(self): + def native_temperature(self): """Return the current temperature.""" return self._data.temperature @property - def pressure(self): + def native_pressure(self): """Return the current pressure.""" return self._data.pressure @@ -157,18 +170,14 @@ class BrWeather(WeatherEntity): return self._data.humidity @property - def visibility(self): - """Return the current visibility in km.""" - if self._data.visibility is None: - return None - return round(self._data.visibility / 1000, 1) + def native_visibility(self): + """Return the current visibility in m.""" + return self._data.visibility @property - def wind_speed(self): - """Return the current windspeed in km/h.""" - if self._data.wind_speed is None: - return None - return round(self._data.wind_speed * 3.6, 1) + def native_wind_speed(self): + """Return the current windspeed in m/s.""" + return self._data.wind_speed @property def wind_bearing(self): @@ -191,11 +200,11 @@ class BrWeather(WeatherEntity): data_out = { ATTR_FORECAST_TIME: data_in.get(DATETIME).isoformat(), ATTR_FORECAST_CONDITION: cond[condcode], - ATTR_FORECAST_TEMP_LOW: data_in.get(MIN_TEMP), - ATTR_FORECAST_TEMP: data_in.get(MAX_TEMP), - ATTR_FORECAST_PRECIPITATION: data_in.get(RAIN), + ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), + ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), + ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), - ATTR_FORECAST_WIND_SPEED: round(data_in.get(WINDSPEED) * 3.6, 1), + ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED), } fcdata_out.append(data_out) diff --git a/homeassistant/components/button/translations/zh-Hans.json b/homeassistant/components/button/translations/zh-Hans.json index 88c70556aa1..12fddc42e13 100644 --- a/homeassistant/components/button/translations/zh-Hans.json +++ b/homeassistant/components/button/translations/zh-Hans.json @@ -1,7 +1,7 @@ { "device_automation": { "action_type": { - "press": "\u6309\u4e0b {entity_name} \u6309\u94ae" + "press": "\u6309\u4e0b {entity_name} \u7684\u6309\u94ae" }, "trigger_type": { "pressed": "{entity_name} \u88ab\u6309\u4e0b" diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index e6945effca4..dc34542dffa 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -2,7 +2,7 @@ "domain": "caldav", "name": "CalDAV", "documentation": "https://www.home-assistant.io/integrations/caldav", - "requirements": ["caldav==0.9.0"], + "requirements": ["caldav==0.9.1"], "codeowners": [], "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"] diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4a6e1546f46..45b77ec1bd6 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import IntEnum from functools import partial -import hashlib import logging import os from random import SystemRandom @@ -387,7 +386,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: continue stream.keepalive = True stream.add_provider("hls") - stream.start() + await stream.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, preload_stream) @@ -455,7 +454,7 @@ class Camera(Entity): def __init__(self) -> None: """Initialize a camera.""" self.stream: Stream | None = None - self.stream_options: dict[str, str | bool] = {} + self.stream_options: dict[str, str | bool | float] = {} self.content_type: str = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) self._warned_old_signature = False @@ -675,9 +674,7 @@ class Camera(Entity): @callback def async_update_token(self) -> None: """Update the used token.""" - self.access_tokens.append( - hashlib.sha256(_RND.getrandbits(256).to_bytes(32, "little")).hexdigest() - ) + self.access_tokens.append(hex(_RND.getrandbits(256))[2:]) async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -999,7 +996,7 @@ async def _async_stream_endpoint_url( stream.keepalive = camera_prefs.preload_stream stream.add_provider(fmt) - stream.start() + await stream.start() return stream.endpoint_url(fmt) diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index d33e98008d6..f668da25e2e 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -3,12 +3,8 @@ from __future__ import annotations from typing import Any -from canary.api import ( - LOCATION_MODE_AWAY, - LOCATION_MODE_HOME, - LOCATION_MODE_NIGHT, - Location, -) +from canary.const import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT +from canary.model import Location from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 5caae6f5cc5..44bac9e3bde 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -5,8 +5,8 @@ from datetime import timedelta from typing import Final from aiohttp.web import Request, StreamResponse -from canary.api import Device, Location from canary.live_stream_api import LiveStreamSession +from canary.model import Device, Location from haffmpeg.camera import CameraMjpeg import voluptuous as vol diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index 4c6c9ce5777..b2a8ef4daaa 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -6,7 +6,8 @@ from datetime import timedelta import logging from async_timeout import timeout -from canary.api import Api, Location +from canary.api import Api +from canary.model import Location from requests.exceptions import ConnectTimeout, HTTPError from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json index fdae5c83d7b..bf7ceaec273 100644 --- a/homeassistant/components/canary/manifest.json +++ b/homeassistant/components/canary/manifest.json @@ -2,7 +2,7 @@ "domain": "canary", "name": "Canary", "documentation": "https://www.home-assistant.io/integrations/canary", - "requirements": ["py-canary==0.5.2"], + "requirements": ["py-canary==0.5.3"], "dependencies": ["ffmpeg"], "codeowners": [], "config_flow": true, diff --git a/homeassistant/components/canary/model.py b/homeassistant/components/canary/model.py index 848278d9aec..12fb8209108 100644 --- a/homeassistant/components/canary/model.py +++ b/homeassistant/components/canary/model.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import ValuesView from typing import Optional, TypedDict -from canary.api import Location +from canary.model import Location class CanaryData(TypedDict): diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 3de088016a9..c80c178fbb5 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Final -from canary.api import Device, Location, SensorType +from canary.model import Device, Location, SensorType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/canary/translations/sv.json b/homeassistant/components/canary/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/canary/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index aaf8d5b9c6c..1c983d6f67a 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -1,8 +1,13 @@ """Config flow for Cast.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import zeroconf +from homeassistant.components import onboarding, zeroconf +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -25,7 +30,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._wanted_uuid = set() @staticmethod - def async_get_options_flow(config_entry): + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> CastOptionsFlowHandler: """Get the options flow for this handler.""" return CastOptionsFlowHandler(config_entry) @@ -94,7 +102,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data = self._get_data() - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self.async_create_entry(title="Google Cast", data=data) return self.async_show_form(step_id="confirm") @@ -110,10 +118,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class CastOptionsFlowHandler(config_entries.OptionsFlow): """Handle Google Cast options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Google Cast options flow.""" self.config_entry = config_entry - self.updated_config = {} + self.updated_config: dict[str, Any] = {} async def async_step_init(self, user_input=None): """Manage the Google Cast options.""" diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 644a517c666..d7cddaaa293 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==12.1.3"], + "requirements": ["pychromecast==12.1.4"], "after_dependencies": [ "cloud", "http", diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index ea21259ccc4..958c53ae394 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -30,6 +30,7 @@ from homeassistant.components import media_source, zeroconf from homeassistant.components.media_player import ( BrowseError, BrowseMedia, + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, async_process_play_media_url, @@ -300,6 +301,12 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): name=str(cast_info.friendly_name), ) + if cast_info.cast_info.cast_type in [ + pychromecast.const.CAST_TYPE_AUDIO, + pychromecast.const.CAST_TYPE_GROUP, + ]: + self._attr_device_class = MediaPlayerDeviceClass.SPEAKER + async def async_added_to_hass(self): """Create chromecast object when added to hass.""" self._async_setup(self.entity_id) diff --git a/homeassistant/components/cast/translations/bg.json b/homeassistant/components/cast/translations/bg.json index 0ab9d863eff..d5103f596e8 100644 --- a/homeassistant/components/cast/translations/bg.json +++ b/homeassistant/components/cast/translations/bg.json @@ -4,6 +4,9 @@ "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Google Cast." }, "step": { + "config": { + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Google Cast" + }, "confirm": { "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Google Cast?" } diff --git a/homeassistant/components/cast/translations/he.json b/homeassistant/components/cast/translations/he.json index d50e5b20684..2d225eb1fed 100644 --- a/homeassistant/components/cast/translations/he.json +++ b/homeassistant/components/cast/translations/he.json @@ -25,6 +25,11 @@ }, "step": { "advanced_options": { + "data": { + "ignore_cec": "\u05dc\u05d4\u05ea\u05e2\u05dc\u05dd \u05de-CEC", + "uuid": "UUIDs \u05de\u05d5\u05ea\u05e8\u05d9\u05dd" + }, + "description": "UUIDs \u05de\u05d5\u05ea\u05e8\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc UUIDs \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9 Cast \u05dc\u05d4\u05d5\u05e1\u05e4\u05d4 \u05d0\u05dc Home Assistant. \u05d9\u05e9 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05e8\u05e7 \u05d0\u05dd \u05d0\u05d9\u05df \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea \u05db\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9 cast \u05d4\u05d6\u05de\u05d9\u05e0\u05d9\u05dd.\n\u05dc\u05d4\u05ea\u05e2\u05dc\u05dd \u05de-CEC - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc Chromecasts \u05e9\u05d0\u05de\u05d5\u05e8\u05d4 \u05dc\u05d4\u05ea\u05e2\u05dc\u05dd \u05de\u05e0\u05ea\u05d5\u05e0\u05d9 CEC \u05dc\u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05e7\u05dc\u05d8 \u05d4\u05e4\u05e2\u05d9\u05dc. \u05d6\u05d4 \u05d9\u05d5\u05e2\u05d1\u05e8 \u05dc- pychromecast.IGNORE_CEC.", "title": "\u05ea\u05e6\u05d5\u05e8\u05d4 \u05de\u05ea\u05e7\u05d3\u05de\u05ea \u05e9\u05dc Google Cast" }, "basic_options": { diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index 74f1c2e1ae5..ec6bed3c55d 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -3,7 +3,6 @@ from http import HTTPStatus import json import logging -from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol @@ -23,7 +22,7 @@ BASE_API_URL = "https://rest.clicksend.com/v3" DEFAULT_SENDER = "hass" TIMEOUT = 5 -HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} +HEADERS = {"Content-Type": CONTENT_TYPE_JSON} PLATFORM_SCHEMA = vol.Schema( diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index 0167cb72513..6aee9b54f6c 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -10,26 +10,22 @@ from pyclimacell.const import CURRENT, DAILY, FORECASTS, HOURLY, NOWCAST from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, CONF_NAME, - LENGTH_FEET, - LENGTH_KILOMETERS, - LENGTH_METERS, + LENGTH_INCHES, LENGTH_MILES, - PRESSURE_HPA, PRESSURE_INHG, - SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, ) @@ -37,8 +33,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util -from homeassistant.util.distance import convert as distance_convert -from homeassistant.util.pressure import convert as pressure_convert from homeassistant.util.speed import convert as speed_convert from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity @@ -89,6 +83,12 @@ async def async_setup_entry( class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Base ClimaCell weather entity.""" + _attr_native_precipitation_unit = LENGTH_INCHES + _attr_native_pressure_unit = PRESSURE_INHG + _attr_native_temperature_unit = TEMP_FAHRENHEIT + _attr_native_visibility_unit = LENGTH_MILES + _attr_native_wind_speed_unit = SPEED_MILES_PER_HOUR + def __init__( self, config_entry: ConfigEntry, @@ -132,30 +132,15 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): else: translated_condition = self._translate_condition(condition, True) - if self.hass.config.units.is_metric: - if precipitation: - precipitation = round( - distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) - * 1000, - 4, - ) - if wind_speed: - wind_speed = round( - speed_convert( - wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ), - 4, - ) - data = { ATTR_FORECAST_TIME: forecast_dt.isoformat(), ATTR_FORECAST_CONDITION: translated_condition, - ATTR_FORECAST_PRECIPITATION: precipitation, + ATTR_FORECAST_NATIVE_PRECIPITATION: precipitation, ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, - ATTR_FORECAST_TEMP: temp, - ATTR_FORECAST_TEMP_LOW: temp_low, + ATTR_FORECAST_NATIVE_TEMP: temp, + ATTR_FORECAST_NATIVE_TEMP_LOW: temp_low, ATTR_FORECAST_WIND_BEARING: wind_direction, - ATTR_FORECAST_WIND_SPEED: wind_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: wind_speed, } return {k: v for k, v in data.items() if v is not None} @@ -164,13 +149,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional state attributes.""" wind_gust = self.wind_gust - if wind_gust and self.hass.config.units.is_metric: - wind_gust = round( - speed_convert( - self.wind_gust, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ), - 4, - ) + wind_gust = round( + speed_convert(self.wind_gust, SPEED_MILES_PER_HOUR, self._wind_speed_unit), + 4, + ) cloud_cover = self.cloud_cover return { ATTR_CLOUD_COVER: cloud_cover, @@ -199,12 +181,8 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the raw pressure.""" @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" - if self.hass.config.units.is_metric and self._pressure: - return round( - pressure_convert(self._pressure, PRESSURE_INHG, PRESSURE_HPA), 4 - ) return self._pressure @property @@ -213,15 +191,8 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the raw wind speed.""" @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - if self.hass.config.units.is_metric and self._wind_speed: - return round( - speed_convert( - self._wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ), - 4, - ) return self._wind_speed @property @@ -230,20 +201,14 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the raw visibility.""" @property - def visibility(self): + def native_visibility(self): """Return the visibility.""" - if self.hass.config.units.is_metric and self._visibility: - return round( - distance_convert(self._visibility, LENGTH_MILES, LENGTH_KILOMETERS), 4 - ) return self._visibility class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): """Entity that talks to ClimaCell v3 API to retrieve weather data.""" - _attr_temperature_unit = TEMP_FAHRENHEIT - @staticmethod def _translate_condition( condition: int | str | None, sun_is_up: bool = True @@ -259,7 +224,7 @@ class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): return CONDITIONS_V3[condition] @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" return self._get_cc_value( self.coordinator.data[CURRENT], CC_V3_ATTR_TEMPERATURE diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index cf52b458a28..1e59c9a6512 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -1,5 +1,8 @@ """Alexa configuration for Home Assistant Cloud.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable from contextlib import suppress from datetime import timedelta from http import HTTPStatus @@ -24,7 +27,15 @@ from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .const import CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE +from .const import ( + CONF_ENTITY_CONFIG, + CONF_FILTER, + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + PREF_SHOULD_EXPOSE, +) from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -54,8 +65,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._token = None self._token_valid = None self._cur_entity_prefs = prefs.alexa_entity_configs - self._cur_default_expose = prefs.alexa_default_expose - self._alexa_sync_unsub = None + self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @property @@ -75,7 +85,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): @property def should_report_state(self): """Return if states should be proactively reported.""" - return self._prefs.alexa_report_state and self.authorized + return ( + self._prefs.alexa_enabled + and self._prefs.alexa_report_state + and self.authorized + ) @property def endpoint(self): @@ -179,7 +193,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) return self._token - async def _async_prefs_updated(self, prefs): + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" if not self._cloud.is_logged_in: if self.is_reporting_states: @@ -190,6 +204,8 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._alexa_sync_unsub = None return + updated_prefs = prefs.last_updated + if ( ALEXA_DOMAIN not in self.hass.config.components and self.enabled @@ -211,28 +227,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): await self.async_sync_entities() return - # If user has filter in config.yaml, don't sync. - if not self._config[CONF_FILTER].empty_filter: - return - - # If entity prefs are the same, don't sync. - if ( - self._cur_entity_prefs is prefs.alexa_entity_configs - and self._cur_default_expose is prefs.alexa_default_expose + # Nothing to do if no Alexa related things have changed + if not any( + key in updated_prefs + for key in ( + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + ) ): return - if self._alexa_sync_unsub: - self._alexa_sync_unsub() - self._alexa_sync_unsub = None + # If we update just entity preferences, delay updating + # as we might update more + if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: + if self._alexa_sync_unsub: + self._alexa_sync_unsub() - if self._cur_default_expose is not prefs.alexa_default_expose: - await self.async_sync_entities() + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) return - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) + await self.async_sync_entities() async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" @@ -243,9 +261,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): seen = set() to_update = [] to_remove = [] + is_enabled = self.enabled for entity_id, info in old_prefs.items(): seen.add(entity_id) + + if not is_enabled: + to_remove.append(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) if entity_id in new_prefs: @@ -291,8 +314,10 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): to_update = [] to_remove = [] + is_enabled = self.enabled + for entity in alexa_entities.async_get_entities(self.hass, self): - if self.should_expose(entity.entity_id): + if is_enabled and self.should_expose(entity.entity_id): to_update.append(entity.entity_id) else: to_remove.append(entity.entity_id) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c47544f9d99..6011e9bf551 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -210,11 +210,11 @@ class CloudClient(Interface): async def async_google_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: """Process cloud google message to client.""" - if not self._prefs.google_enabled: - return ga.turned_off_response(payload) - gconf = await self.get_google_config() + if not self._prefs.google_enabled: + return ga.api_disabled_response(payload, gconf.agent_user_id) + return await ga.async_handle_message( self._hass, gconf, gconf.cloud_user, payload, gc.SOURCE_CLOUD ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 81f00b69b23..9bb2e405dca 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -219,6 +219,7 @@ class CloudGoogleConfig(AbstractConfig): sync_entities = True elif not self.enabled and self.is_local_sdk_active: self.async_disable_local_sdk() + sync_entities = True self._cur_entity_prefs = prefs.google_entity_configs self._cur_default_expose = prefs.google_default_expose diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 275c2a56326..17ec00026bc 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -50,6 +50,7 @@ class CloudPreferences: self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) self._prefs = None self._listeners = [] + self.last_updated: set[str] = set() async def async_initialize(self): """Finish initializing the preferences.""" @@ -308,6 +309,9 @@ class CloudPreferences: async def _save_prefs(self, prefs): """Save preferences to disk.""" + self.last_updated = { + key for key, value in prefs.items() if value != self._prefs.get(key) + } self._prefs = prefs await self._store.async_save(self._prefs) diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 121e0fc9974..215411bc667 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Cloudflare integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -97,7 +98,7 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): self.zones: list[str] | None = None self.records: list[str] | None = None - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Cloudflare.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/cloudflare/translations/bg.json b/homeassistant/components/cloudflare/translations/bg.json index ec50ba10dc8..84593a40a00 100644 --- a/homeassistant/components/cloudflare/translations/bg.json +++ b/homeassistant/components/cloudflare/translations/bg.json @@ -11,6 +11,11 @@ }, "flow_title": "{name}", "step": { + "records": { + "data": { + "records": "\u0417\u0430\u043f\u0438\u0441\u0438" + } + }, "user": { "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Cloudflare" }, diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 0687bd3f305..6582acc6549 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Coinbase integration.""" +from __future__ import annotations + import logging from coinbase.wallet.client import Client @@ -160,7 +162,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/coinbase/translations/es.json b/homeassistant/components/coinbase/translations/es.json index 5454aca8ec6..7ecd0a753c9 100644 --- a/homeassistant/components/coinbase/translations/es.json +++ b/homeassistant/components/coinbase/translations/es.json @@ -23,6 +23,7 @@ }, "options": { "error": { + "currency_unavailable": "La API de Coinbase no proporciona uno o m\u00e1s de los saldos de divisas solicitados.", "exchange_rate_unavailable": "El API de Coinbase no proporciona alguno/s de los tipos de cambio que has solicitado.", "unknown": "Error inesperado" }, diff --git a/homeassistant/components/coinbase/translations/sv.json b/homeassistant/components/coinbase/translations/sv.json index 5610f0c79fd..1ab71904a13 100644 --- a/homeassistant/components/coinbase/translations/sv.json +++ b/homeassistant/components/coinbase/translations/sv.json @@ -1,4 +1,13 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + }, "options": { "error": { "currency_unavailable": "En eller flera av de beg\u00e4rda valutasaldona tillhandah\u00e5lls inte av ditt Coinbase API.", diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 84d82c170e1..dd2c9632d7c 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -53,12 +53,16 @@ def setup_platform( class ComfoConnectFan(FanEntity): """Representation of the ComfoConnect fan platform.""" + _attr_icon = "mdi:air-conditioner" + _attr_should_poll = False _attr_supported_features = FanEntityFeature.SET_SPEED current_speed = None def __init__(self, ccb: ComfoConnectBridge) -> None: """Initialize the ComfoConnect fan.""" self._ccb = ccb + self._attr_name = ccb.name + self._attr_unique_id = ccb.unique_id async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -74,7 +78,7 @@ class ComfoConnectFan(FanEntity): self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE ) - def _handle_update(self, value): + def _handle_update(self, value: float) -> None: """Handle update callbacks.""" _LOGGER.debug( "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value @@ -82,26 +86,6 @@ class ComfoConnectFan(FanEntity): self.current_speed = value self.schedule_update_ha_state() - @property - def should_poll(self) -> bool: - """Do not poll.""" - return False - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._ccb.unique_id - - @property - def name(self): - """Return the name of the fan.""" - return self._ccb.name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:air-conditioner" - @property def percentage(self) -> int | None: """Return the current speed percentage.""" @@ -118,7 +102,7 @@ class ComfoConnectFan(FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" if percentage is None: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 321b18437d9..609166f2d16 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -153,14 +153,14 @@ class CommandCover(CoverEntity): payload = self._value_template.render_with_possible_json_value(payload) self._state = int(payload) - def open_cover(self, **kwargs) -> None: + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._move_cover(self._command_open) - def close_cover(self, **kwargs) -> None: + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._move_cover(self._command_close) - def stop_cover(self, **kwargs) -> None: + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._move_cover(self._command_stop) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 213e8888e23..509d5740b22 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,7 @@ "domain": "compensation", "name": "Compensation", "documentation": "https://www.home-assistant.io/integrations/compensation", - "requirements": ["numpy==1.21.6"], + "requirements": ["numpy==1.23.0"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index e8f21c46278..de5d4495a85 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -72,6 +72,8 @@ def setup_platform( class Concord232Alarm(alarm.AlarmControlPanelEntity): """Representation of the Concord232-based alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -80,30 +82,14 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): def __init__(self, url, name, code, mode): """Initialize the Concord232 alarm panel.""" - self._state = None - self._name = name + self._attr_name = name self._code = code self._mode = mode self._url = url self._alarm = concord232_client.Client(self._url) self._alarm.partitions = self._alarm.list_partitions() - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def code_format(self): - """Return the characters if code is defined.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - - def update(self): + def update(self) -> None: """Update values from API.""" try: part = self._alarm.list_partitions()[0] @@ -118,19 +104,19 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): return if part["arming_level"] == "Off": - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif "Home" in part["arming_level"]: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME else: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): return self._alarm.disarm(code) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return @@ -139,7 +125,7 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): else: self._alarm.arm("stay") - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return @@ -152,7 +138,7 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): if isinstance(self._code, str): alarm_code = self._code else: - alarm_code = self._code.render(from_state=self._state, to_state=state) + alarm_code = self._code.render(from_state=self._attr_state, to_state=state) check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 0a093ee4574..ac452666103 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -3,22 +3,29 @@ from __future__ import annotations import asyncio from http import HTTPStatus +from typing import Any from aiohttp import web import aiohttp.web_exceptions import voluptuous as vol -from homeassistant import config_entries, data_entry_flow, loader +from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, ) -from homeassistant.loader import Integration, async_get_config_flows +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_config_flows, + async_get_integration, +) async def async_setup(hass): @@ -33,6 +40,7 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_progress) @@ -50,49 +58,13 @@ class ConfigManagerEntryIndexView(HomeAssistantView): async def get(self, request): """List available config entries.""" hass: HomeAssistant = request.app["hass"] - - kwargs = {} + domain = None if "domain" in request.query: - kwargs["domain"] = request.query["domain"] - - entries = hass.config_entries.async_entries(**kwargs) - - if "type" not in request.query: - return self.json([entry_json(entry) for entry in entries]) - - integrations = {} - type_filter = request.query["type"] - - async def load_integration( - hass: HomeAssistant, domain: str - ) -> Integration | None: - """Load integration.""" - try: - return await loader.async_get_integration(hass, domain) - except loader.IntegrationNotFound: - return None - - # Fetch all the integrations so we can check their type - for integration in await asyncio.gather( - *( - load_integration(hass, domain) - for domain in {entry.domain for entry in entries} - ) - ): - if integration: - integrations[integration.domain] = integration - - entries = [ - entry - for entry in entries - if (type_filter != "helper" and entry.domain not in integrations) - or ( - entry.domain in integrations - and integrations[entry.domain].integration_type == type_filter - ) - ] - - return self.json([entry_json(entry) for entry in entries]) + domain = request.query["domain"] + type_filter = None + if "type" in request.query: + type_filter = request.query["type"] + return self.json(await async_matching_config_entries(hass, type_filter, domain)) class ConfigManagerEntryResourceView(HomeAssistantView): @@ -143,7 +115,7 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): def _prepare_config_flow_result_json(result, prepare_result_json): """Convert result to JSON.""" - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return prepare_result_json(result) data = result.copy() @@ -415,6 +387,64 @@ async def ignore_config_flow(hass, connection, msg): connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + vol.Required("type"): "config_entries/get", + vol.Optional("type_filter"): str, + vol.Optional("domain"): str, + } +) +@websocket_api.async_response +async def config_entries_get( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return matching config entries by type and/or domain.""" + connection.send_result( + msg["id"], + await async_matching_config_entries( + hass, msg.get("type_filter"), msg.get("domain") + ), + ) + + +async def async_matching_config_entries( + hass: HomeAssistant, type_filter: str | None, domain: str | None +) -> list[dict[str, Any]]: + """Return matching config entries by type and/or domain.""" + kwargs = {} + if domain: + kwargs["domain"] = domain + entries = hass.config_entries.async_entries(**kwargs) + + if type_filter is None: + return [entry_json(entry) for entry in entries] + + integrations = {} + # Fetch all the integrations so we can check their type + tasks = ( + async_get_integration(hass, domain) + for domain in {entry.domain for entry in entries} + ) + results = await asyncio.gather(*tasks, return_exceptions=True) + for integration_or_exc in results: + if isinstance(integration_or_exc, Integration): + integrations[integration_or_exc.domain] = integration_or_exc + elif not isinstance(integration_or_exc, IntegrationNotFound): + raise integration_or_exc + + entries = [ + entry + for entry in entries + if (type_filter != "helper" and entry.domain not in integrations) + or ( + entry.domain in integrations + and integrations[entry.domain].integration_type == type_filter + ) + ] + + return [entry_json(entry) for entry in entries] + + @callback def entry_json(entry: config_entries.ConfigEntry) -> dict: """Return JSON value of a config entry.""" diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 2bb585e12c6..e6b91ee5a50 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -218,6 +218,7 @@ def _entry_ext_dict(entry): data = _entry_dict(entry) data["capabilities"] = entry.capabilities data["device_class"] = entry.device_class + data["has_entity_name"] = entry.has_entity_name data["options"] = entry.options data["original_device_class"] = entry.original_device_class data["original_icon"] = entry.original_icon diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 2cf1ca845f7..05fd8a2b7c8 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Control4 integration.""" +from __future__ import annotations + from asyncio import TimeoutError as asyncioTimeoutError import logging @@ -136,7 +138,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/control4/translations/sv.json b/homeassistant/components/control4/translations/sv.json new file mode 100644 index 00000000000..e1ecf8798c1 --- /dev/null +++ b/homeassistant/components/control4/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index aecca5a4029..b66398b3491 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -1,13 +1,15 @@ """Support for Cover devices.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from enum import IntEnum import functools as ft import logging -from typing import Any, final +from typing import Any, TypeVar, final +from typing_extensions import ParamSpec import voluptuous as vol from homeassistant.backports.enum import StrEnum @@ -38,8 +40,6 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs - _LOGGER = logging.getLogger(__name__) DOMAIN = "cover" @@ -47,6 +47,9 @@ SCAN_INTERVAL = timedelta(seconds=15) ENTITY_ID_FORMAT = DOMAIN + ".{}" +_P = ParamSpec("_P") +_R = TypeVar("_R") + class CoverDeviceClass(StrEnum): """Device class for cover.""" @@ -112,7 +115,7 @@ ATTR_TILT_POSITION = "tilt_position" @bind_hass -def is_closed(hass, entity_id): +def is_closed(hass: HomeAssistant, entity_id: str) -> bool: """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(entity_id, STATE_CLOSED) @@ -273,7 +276,7 @@ class CoverEntity(Entity): @final @property - def state_attributes(self): + def state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" data = {} @@ -327,7 +330,7 @@ class CoverEntity(Entity): """Open the cover.""" raise NotImplementedError() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.hass.async_add_executor_job(ft.partial(self.open_cover, **kwargs)) @@ -335,7 +338,7 @@ class CoverEntity(Entity): """Close cover.""" raise NotImplementedError() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self.hass.async_add_executor_job(ft.partial(self.close_cover, **kwargs)) @@ -349,7 +352,7 @@ class CoverEntity(Entity): function = self._get_toggle_function(fns) function(**kwargs) - async def async_toggle(self, **kwargs): + async def async_toggle(self, **kwargs: Any) -> None: """Toggle the entity.""" fns = { "open": self.async_open_cover, @@ -359,26 +362,26 @@ class CoverEntity(Entity): function = self._get_toggle_function(fns) await function(**kwargs) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.hass.async_add_executor_job( ft.partial(self.set_cover_position, **kwargs) ) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.hass.async_add_executor_job(ft.partial(self.stop_cover, **kwargs)) def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" await self.hass.async_add_executor_job( ft.partial(self.open_cover_tilt, **kwargs) @@ -387,25 +390,25 @@ class CoverEntity(Entity): def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" await self.hass.async_add_executor_job( ft.partial(self.close_cover_tilt, **kwargs) ) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" await self.hass.async_add_executor_job( ft.partial(self.set_cover_tilt_position, **kwargs) ) - def stop_cover_tilt(self, **kwargs): + def stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" await self.hass.async_add_executor_job( ft.partial(self.stop_cover_tilt, **kwargs) @@ -418,14 +421,16 @@ class CoverEntity(Entity): else: self.close_cover_tilt(**kwargs) - async def async_toggle_tilt(self, **kwargs): + async def async_toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" if self.current_cover_tilt_position == 0: await self.async_open_cover_tilt(**kwargs) else: await self.async_close_cover_tilt(**kwargs) - def _get_toggle_function(self, fns): + def _get_toggle_function( + self, fns: dict[str, Callable[_P, _R]] + ) -> Callable[_P, _R]: if CoverEntityFeature.STOP | self.supported_features and ( self.is_closing or self.is_opening ): diff --git a/homeassistant/components/cover/translations/sv.json b/homeassistant/components/cover/translations/sv.json index a9509740330..624b5102d82 100644 --- a/homeassistant/components/cover/translations/sv.json +++ b/homeassistant/components/cover/translations/sv.json @@ -9,8 +9,8 @@ "is_closing": "{entity_name} st\u00e4ngs", "is_open": "{entity_name} \u00e4r \u00f6ppen", "is_opening": "{entity_name} \u00f6ppnas", - "is_position": "Aktuell position f\u00f6r {entity_name} \u00e4r", - "is_tilt_position": "Aktuell {entity_name} lutningsposition \u00e4r" + "is_position": "Nuvarande position f\u00f6r {entity_name} \u00e4r", + "is_tilt_position": "Nuvarande {entity_name} lutningsposition \u00e4r" }, "trigger_type": { "closed": "{entity_name} st\u00e4ngd", diff --git a/homeassistant/components/daikin/translations/bg.json b/homeassistant/components/daikin/translations/bg.json index b2d1963e4cb..a07f37ab8d5 100644 --- a/homeassistant/components/daikin/translations/bg.json +++ b/homeassistant/components/daikin/translations/bg.json @@ -5,7 +5,8 @@ "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "error": { - "api_password": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 API \u043a\u043b\u044e\u0447 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430." + "api_password": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 API \u043a\u043b\u044e\u0447 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430.", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { diff --git a/homeassistant/components/daikin/translations/sv.json b/homeassistant/components/daikin/translations/sv.json index a704822f0b7..03b7358ee1b 100644 --- a/homeassistant/components/daikin/translations/sv.json +++ b/homeassistant/components/daikin/translations/sv.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "api_key": "API-nyckel", "host": "V\u00e4rddatorn", "password": "Enhetsl\u00f6senord (anv\u00e4nds endast av SKYFi-enheter)" }, diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py index 4965505150a..1bc56706007 100644 --- a/homeassistant/components/darksky/weather.py +++ b/homeassistant/components/darksky/weather.py @@ -21,12 +21,12 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity, ) @@ -36,10 +36,11 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MODE, CONF_NAME, - PRESSURE_HPA, - PRESSURE_INHG, + LENGTH_KILOMETERS, + LENGTH_MILLIMETERS, + PRESSURE_MBAR, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, - TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -47,7 +48,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.pressure import convert as convert_pressure _LOGGER = logging.getLogger(__name__) @@ -75,15 +75,18 @@ CONF_UNITS = "units" DEFAULT_NAME = "Dark Sky" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), - vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } +PLATFORM_SCHEMA = vol.All( + cv.removed(CONF_UNITS), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), + vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } + ), ) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3) @@ -101,9 +104,7 @@ def setup_platform( name = config.get(CONF_NAME) mode = config.get(CONF_MODE) - if not (units := config.get(CONF_UNITS)): - units = "ca" if hass.config.units.is_metric else "us" - + units = "si" dark_sky = DarkSkyData(config.get(CONF_API_KEY), latitude, longitude, units) add_entities([DarkSkyWeather(name, dark_sky, mode)], True) @@ -112,6 +113,12 @@ def setup_platform( class DarkSkyWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_MBAR + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + def __init__(self, name, dark_sky, mode): """Initialize Dark Sky weather.""" self._name = name @@ -139,24 +146,17 @@ class DarkSkyWeather(WeatherEntity): return self._name @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self._ds_currently.get("temperature") - @property - def temperature_unit(self): - """Return the unit of measurement.""" - if self._dark_sky.units is None: - return None - return TEMP_FAHRENHEIT if "us" in self._dark_sky.units else TEMP_CELSIUS - @property def humidity(self): """Return the humidity.""" return round(self._ds_currently.get("humidity") * 100.0, 2) @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self._ds_currently.get("windSpeed") @@ -171,15 +171,12 @@ class DarkSkyWeather(WeatherEntity): return self._ds_currently.get("ozone") @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" - pressure = self._ds_currently.get("pressure") - if "us" in self._dark_sky.units: - return round(convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2) - return pressure + return self._ds_currently.get("pressure") @property - def visibility(self): + def native_visibility(self): """Return the visibility.""" return self._ds_currently.get("visibility") @@ -208,12 +205,12 @@ class DarkSkyWeather(WeatherEntity): ATTR_FORECAST_TIME: utc_from_timestamp( entry.d.get("time") ).isoformat(), - ATTR_FORECAST_TEMP: entry.d.get("temperatureHigh"), - ATTR_FORECAST_TEMP_LOW: entry.d.get("temperatureLow"), - ATTR_FORECAST_PRECIPITATION: calc_precipitation( + ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperatureHigh"), + ATTR_FORECAST_NATIVE_TEMP_LOW: entry.d.get("temperatureLow"), + ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( entry.d.get("precipIntensity"), 24 ), - ATTR_FORECAST_WIND_SPEED: entry.d.get("windSpeed"), + ATTR_FORECAST_NATIVE_WIND_SPEED: entry.d.get("windSpeed"), ATTR_FORECAST_WIND_BEARING: entry.d.get("windBearing"), ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), } @@ -225,8 +222,8 @@ class DarkSkyWeather(WeatherEntity): ATTR_FORECAST_TIME: utc_from_timestamp( entry.d.get("time") ).isoformat(), - ATTR_FORECAST_TEMP: entry.d.get("temperature"), - ATTR_FORECAST_PRECIPITATION: calc_precipitation( + ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperature"), + ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( entry.d.get("precipIntensity"), 1 ), ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), @@ -281,10 +278,3 @@ class DarkSkyData: self._connect_error = True _LOGGER.error("Unable to connect to Dark Sky. %s", error) self.data = None - - @property - def units(self): - """Get the unit system of returned data.""" - if self.data is None: - return None - return self.data.json.get("flags").get("units") diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 90c34da0f12..bf0f39b75d0 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -1,20 +1,11 @@ """Support for deCONZ alarm control panel devices.""" from __future__ import annotations -from pydeconz.interfaces.alarm_systems import ArmAction +from pydeconz.models.alarm_system import AlarmSystemArmAction from pydeconz.models.event import EventType from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY, - ANCILLARY_CONTROL_ARMING_AWAY, - ANCILLARY_CONTROL_ARMING_NIGHT, - ANCILLARY_CONTROL_ARMING_STAY, - ANCILLARY_CONTROL_DISARMED, - ANCILLARY_CONTROL_ENTRY_DELAY, - ANCILLARY_CONTROL_EXIT_DELAY, - ANCILLARY_CONTROL_IN_ALARM, AncillaryControl, + AncillaryControlPanel, ) from homeassistant.components.alarm_control_panel import ( @@ -40,16 +31,16 @@ from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_TO_ALARM_STATE = { - ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, - ANCILLARY_CONTROL_ARMING_AWAY: STATE_ALARM_ARMING, - ANCILLARY_CONTROL_ARMING_NIGHT: STATE_ALARM_ARMING, - ANCILLARY_CONTROL_ARMING_STAY: STATE_ALARM_ARMING, - ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, - ANCILLARY_CONTROL_ENTRY_DELAY: STATE_ALARM_PENDING, - ANCILLARY_CONTROL_EXIT_DELAY: STATE_ALARM_PENDING, - ANCILLARY_CONTROL_IN_ALARM: STATE_ALARM_TRIGGERED, + AncillaryControlPanel.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + AncillaryControlPanel.ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, + AncillaryControlPanel.ARMED_STAY: STATE_ALARM_ARMED_HOME, + AncillaryControlPanel.ARMING_AWAY: STATE_ALARM_ARMING, + AncillaryControlPanel.ARMING_NIGHT: STATE_ALARM_ARMING, + AncillaryControlPanel.ARMING_STAY: STATE_ALARM_ARMING, + AncillaryControlPanel.DISARMED: STATE_ALARM_DISARMED, + AncillaryControlPanel.ENTRY_DELAY: STATE_ALARM_PENDING, + AncillaryControlPanel.EXIT_DELAY: STATE_ALARM_PENDING, + AncillaryControlPanel.IN_ALARM: STATE_ALARM_TRIGGERED, } @@ -133,26 +124,26 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): """Send arm away command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.AWAY, code + self.alarm_system_id, AlarmSystemArmAction.AWAY, code ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.STAY, code + self.alarm_system_id, AlarmSystemArmAction.STAY, code ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.NIGHT, code + self.alarm_system_id, AlarmSystemArmAction.NIGHT, code ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.DISARM, code + self.alarm_system_id, AlarmSystemArmAction.DISARM, code ) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index d109fb8b34d..0d090751edd 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -27,8 +27,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er -from .const import ATTR_DARK, ATTR_ON +from .const import ATTR_DARK, ATTR_ON, DOMAIN as DECONZ_DOMAIN from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry @@ -179,6 +180,27 @@ BINARY_SENSOR_DESCRIPTIONS = [ ] +@callback +def async_update_unique_id( + hass: HomeAssistant, unique_id: str, description: DeconzBinarySensorDescription +) -> None: + """Update unique ID to always have a suffix. + + Introduced with release 2022.7. + """ + ent_reg = er.async_get(hass) + + new_unique_id = f"{unique_id}-{description.key}" + if ent_reg.async_get_entity_id(DOMAIN, DECONZ_DOMAIN, new_unique_id): + return + + if description.suffix: + unique_id = f'{unique_id.split("-", 1)[0]}-{description.suffix.lower()}' + + if entity_id := ent_reg.async_get_entity_id(DOMAIN, DECONZ_DOMAIN, unique_id): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -205,6 +227,8 @@ async def async_setup_entry( ): continue + async_update_unique_id(hass, sensor.unique_id, description) + async_add_entities([DeconzBinarySensor(sensor, gateway, description)]) gateway.register_platform_add_device_callback( @@ -255,9 +279,7 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): @property def unique_id(self) -> str: """Return a unique identifier for this device.""" - if self.entity_description.suffix: - return f"{self.serial}-{self.entity_description.suffix.lower()}" - return super().unique_id + return f"{super().unique_id}-{self.entity_description.key}" @callback def async_update_callback(self) -> None: diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 6887b4238d2..880e11f080b 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -5,25 +5,10 @@ from typing import Any from pydeconz.models.event import EventType from pydeconz.models.sensor.thermostat import ( - THERMOSTAT_FAN_MODE_AUTO, - THERMOSTAT_FAN_MODE_HIGH, - THERMOSTAT_FAN_MODE_LOW, - THERMOSTAT_FAN_MODE_MEDIUM, - THERMOSTAT_FAN_MODE_OFF, - THERMOSTAT_FAN_MODE_ON, - THERMOSTAT_FAN_MODE_SMART, - THERMOSTAT_MODE_AUTO, - THERMOSTAT_MODE_COOL, - THERMOSTAT_MODE_HEAT, - THERMOSTAT_MODE_OFF, - THERMOSTAT_PRESET_AUTO, - THERMOSTAT_PRESET_BOOST, - THERMOSTAT_PRESET_COMFORT, - THERMOSTAT_PRESET_COMPLEX, - THERMOSTAT_PRESET_ECO, - THERMOSTAT_PRESET_HOLIDAY, - THERMOSTAT_PRESET_MANUAL, Thermostat, + ThermostatFanMode, + ThermostatMode, + ThermostatPreset, ) from homeassistant.components.climate import DOMAIN, ClimateEntity @@ -53,21 +38,21 @@ from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_FAN_SMART = "smart" FAN_MODE_TO_DECONZ = { - DECONZ_FAN_SMART: THERMOSTAT_FAN_MODE_SMART, - FAN_AUTO: THERMOSTAT_FAN_MODE_AUTO, - FAN_HIGH: THERMOSTAT_FAN_MODE_HIGH, - FAN_MEDIUM: THERMOSTAT_FAN_MODE_MEDIUM, - FAN_LOW: THERMOSTAT_FAN_MODE_LOW, - FAN_ON: THERMOSTAT_FAN_MODE_ON, - FAN_OFF: THERMOSTAT_FAN_MODE_OFF, + DECONZ_FAN_SMART: ThermostatFanMode.SMART, + FAN_AUTO: ThermostatFanMode.AUTO, + FAN_HIGH: ThermostatFanMode.HIGH, + FAN_MEDIUM: ThermostatFanMode.MEDIUM, + FAN_LOW: ThermostatFanMode.LOW, + FAN_ON: ThermostatFanMode.ON, + FAN_OFF: ThermostatFanMode.OFF, } DECONZ_TO_FAN_MODE = {value: key for key, value in FAN_MODE_TO_DECONZ.items()} -HVAC_MODE_TO_DECONZ: dict[HVACMode, str] = { - HVACMode.AUTO: THERMOSTAT_MODE_AUTO, - HVACMode.COOL: THERMOSTAT_MODE_COOL, - HVACMode.HEAT: THERMOSTAT_MODE_HEAT, - HVACMode.OFF: THERMOSTAT_MODE_OFF, +HVAC_MODE_TO_DECONZ = { + HVACMode.AUTO: ThermostatMode.AUTO, + HVACMode.COOL: ThermostatMode.COOL, + HVACMode.HEAT: ThermostatMode.HEAT, + HVACMode.OFF: ThermostatMode.OFF, } DECONZ_PRESET_AUTO = "auto" @@ -76,13 +61,13 @@ DECONZ_PRESET_HOLIDAY = "holiday" DECONZ_PRESET_MANUAL = "manual" PRESET_MODE_TO_DECONZ = { - DECONZ_PRESET_AUTO: THERMOSTAT_PRESET_AUTO, - PRESET_BOOST: THERMOSTAT_PRESET_BOOST, - PRESET_COMFORT: THERMOSTAT_PRESET_COMFORT, - DECONZ_PRESET_COMPLEX: THERMOSTAT_PRESET_COMPLEX, - PRESET_ECO: THERMOSTAT_PRESET_ECO, - DECONZ_PRESET_HOLIDAY: THERMOSTAT_PRESET_HOLIDAY, - DECONZ_PRESET_MANUAL: THERMOSTAT_PRESET_MANUAL, + DECONZ_PRESET_AUTO: ThermostatPreset.AUTO, + PRESET_BOOST: ThermostatPreset.BOOST, + PRESET_COMFORT: ThermostatPreset.COMFORT, + DECONZ_PRESET_COMPLEX: ThermostatPreset.COMPLEX, + PRESET_ECO: ThermostatPreset.ECO, + DECONZ_PRESET_HOLIDAY: ThermostatPreset.HOLIDAY, + DECONZ_PRESET_MANUAL: ThermostatPreset.MANUAL, } DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.items()} @@ -168,23 +153,23 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): """Return fan operation.""" if self._device.fan_mode in DECONZ_TO_FAN_MODE: return DECONZ_TO_FAN_MODE[self._device.fan_mode] - return DECONZ_TO_FAN_MODE[FAN_ON if self._device.state_on else FAN_OFF] + return FAN_ON if self._device.state_on else FAN_OFF async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if fan_mode not in FAN_MODE_TO_DECONZ: raise ValueError(f"Unsupported fan mode {fan_mode}") - await self._device.set_config(fan_mode=FAN_MODE_TO_DECONZ[fan_mode]) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + fan_mode=FAN_MODE_TO_DECONZ[fan_mode], + ) # HVAC control @property def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ + """Return hvac operation ie. heat, cool mode.""" if self._device.mode in self._deconz_to_hvac_mode: return self._deconz_to_hvac_mode[self._device.mode] return HVACMode.HEAT if self._device.state_on else HVACMode.OFF @@ -195,9 +180,15 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): raise ValueError(f"Unsupported HVAC mode {hvac_mode}") if len(self._attr_hvac_modes) == 2: # Only allow turn on and off thermostat - await self._device.set_config(on=hvac_mode != HVACMode.OFF) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + on=hvac_mode != HVACMode.OFF, + ) else: - await self._device.set_config(mode=HVAC_MODE_TO_DECONZ[hvac_mode]) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + mode=HVAC_MODE_TO_DECONZ[hvac_mode], + ) # Preset control @@ -213,7 +204,10 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if preset_mode not in PRESET_MODE_TO_DECONZ: raise ValueError(f"Unsupported preset mode {preset_mode}") - await self._device.set_config(preset=PRESET_MODE_TO_DECONZ[preset_mode]) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + preset=PRESET_MODE_TO_DECONZ[preset_mode], + ) # Temperature control @@ -225,11 +219,11 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the target temperature.""" - if self._device.mode == THERMOSTAT_MODE_COOL and self._device.cooling_setpoint: - return self._device.cooling_setpoint + if self._device.mode == ThermostatMode.COOL and self._device.cooling_setpoint: + return self._device.scaled_cooling_setpoint if self._device.heating_setpoint: - return self._device.heating_setpoint + return self._device.scaled_heating_setpoint return None @@ -238,11 +232,16 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - data = {"heating_setpoint": kwargs[ATTR_TEMPERATURE] * 100} - if self._device.mode == "cool": - data = {"cooling_setpoint": kwargs[ATTR_TEMPERATURE] * 100} - - await self._device.set_config(**data) + if self._device.mode == ThermostatMode.COOL: + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + cooling_setpoint=kwargs[ATTR_TEMPERATURE] * 100, + ) + else: + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + heating_setpoint=kwargs[ATTR_TEMPERATURE] * 100, + ) @property def extra_state_attributes(self) -> dict[str, bool | int]: diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 28205a7382d..d94b40e8525 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from pprint import pformat from typing import Any, cast from urllib.parse import urlparse @@ -204,12 +205,12 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" - self.context["title_placeholders"] = {CONF_HOST: config[CONF_HOST]} + self.context["title_placeholders"] = {CONF_HOST: entry_data[CONF_HOST]} - self.host = config[CONF_HOST] - self.port = config[CONF_PORT] + self.host = entry_data[CONF_HOST] + self.port = entry_data[CONF_PORT] return await self.async_step_link() diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index fa53ef1b5bc..270e66bf91d 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -6,11 +6,8 @@ from typing import Any from pydeconz.models.event import EventType from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_EMERGENCY, - ANCILLARY_CONTROL_FIRE, - ANCILLARY_CONTROL_INVALID_CODE, - ANCILLARY_CONTROL_PANIC, AncillaryControl, + AncillaryControlAction, ) from pydeconz.models.sensor.switch import Switch @@ -33,10 +30,10 @@ CONF_DECONZ_EVENT = "deconz_event" CONF_DECONZ_ALARM_EVENT = "deconz_alarm_event" SUPPORTED_DECONZ_ALARM_EVENTS = { - ANCILLARY_CONTROL_EMERGENCY, - ANCILLARY_CONTROL_FIRE, - ANCILLARY_CONTROL_INVALID_CODE, - ANCILLARY_CONTROL_PANIC, + AncillaryControlAction.EMERGENCY, + AncillaryControlAction.FIRE, + AncillaryControlAction.INVALID_CODE, + AncillaryControlAction.PANIC, } @@ -183,7 +180,7 @@ class DeconzAlarmEvent(DeconzEventBase): CONF_ID: self.event_id, CONF_UNIQUE_ID: self.serial, CONF_DEVICE_ID: self.device_id, - CONF_EVENT: self._device.action, + CONF_EVENT: self._device.action.value, } self.gateway.hass.bus.async_fire(CONF_DECONZ_ALARM_EVENT, data) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 5890e372e66..25471d1448a 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast import async_timeout from pydeconz import DeconzSession, errors from pydeconz.interfaces.api import APIItems, GroupedAPIItems +from pydeconz.interfaces.groups import Groups from pydeconz.models.event import EventType from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry @@ -59,18 +60,18 @@ class DeconzGateway: self.ignore_state_updates = False self.signal_reachable = f"deconz-reachable-{config_entry.entry_id}" - self.signal_reload_groups = f"deconz_reload_group_{config_entry.entry_id}" self.signal_reload_clip_sensors = f"deconz_reload_clip_{config_entry.entry_id}" self.deconz_ids: dict[str, str] = {} self.entities: dict[str, set[str]] = {} self.events: list[DeconzAlarmEvent | DeconzEvent] = [] self.ignored_devices: set[tuple[Callable[[EventType, str], None], str]] = set() + self.deconz_groups: set[tuple[Callable[[EventType, str], None], str]] = set() - self._option_allow_deconz_groups = self.config_entry.options.get( + self.option_allow_deconz_groups = config_entry.options.get( CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS ) - self.option_allow_new_devices = self.config_entry.options.get( + self.option_allow_new_devices = config_entry.options.get( CONF_ALLOW_NEW_DEVICES, DEFAULT_ALLOW_NEW_DEVICES ) @@ -98,13 +99,6 @@ class DeconzGateway: CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR ) - @property - def option_allow_deconz_groups(self) -> bool: - """Allow loading deCONZ groups from gateway.""" - return self.config_entry.options.get( - CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS - ) - @callback def register_platform_add_device_callback( self, @@ -113,16 +107,28 @@ class DeconzGateway: ) -> None: """Wrap add_device_callback to check allow_new_devices option.""" - def async_add_device(event: EventType, device_id: str) -> None: + initializing = True + + def async_add_device(_: EventType, device_id: str) -> None: """Add device or add it to ignored_devices set. If ignore_state_updates is True means device_refresh service is used. Device_refresh is expected to load new devices. """ - if not self.option_allow_new_devices and not self.ignore_state_updates: + if ( + not initializing + and not self.option_allow_new_devices + and not self.ignore_state_updates + ): self.ignored_devices.add((async_add_device, device_id)) return - add_device_callback(event, device_id) + + if isinstance(deconz_device_interface, Groups): + self.deconz_groups.add((async_add_device, device_id)) + if not self.option_allow_deconz_groups: + return + + add_device_callback(EventType.ADDED, device_id) self.config_entry.async_on_unload( deconz_device_interface.subscribe( @@ -132,7 +138,9 @@ class DeconzGateway: ) for device_id in deconz_device_interface: - add_device_callback(EventType.ADDED, device_id) + async_add_device(EventType.ADDED, device_id) + + initializing = False @callback def load_ignored_devices(self) -> None: @@ -216,13 +224,16 @@ class DeconzGateway: # Allow Groups - if self.option_allow_deconz_groups: - if not self._option_allow_deconz_groups: - async_dispatcher_send(self.hass, self.signal_reload_groups) - else: - deconz_ids += [group.deconz_id for group in self.api.groups.values()] - - self._option_allow_deconz_groups = self.option_allow_deconz_groups + option_allow_deconz_groups = self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ) + if option_allow_deconz_groups != self.option_allow_deconz_groups: + self.option_allow_deconz_groups = option_allow_deconz_groups + if option_allow_deconz_groups: + for add_device, device_id in self.deconz_groups: + add_device(EventType.ADDED, device_id) + else: + deconz_ids += [group.deconz_id for group in self.api.groups.values()] # Allow adding new devices @@ -287,7 +298,6 @@ async def get_deconz_session( config[CONF_HOST], config[CONF_PORT], config[CONF_API_KEY], - legacy_add_device=False, ) try: async with async_timeout.timeout(10): diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 53773369176..669800e2662 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -33,7 +33,6 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hs_to_xy @@ -105,39 +104,30 @@ async def async_setup_entry( @callback def async_add_group(_: EventType, group_id: str) -> None: - """Add group from deCONZ.""" - if ( - not gateway.option_allow_deconz_groups - or (group := gateway.api.groups[group_id]) - and not group.lights - ): + """Add group from deCONZ. + + Update group states based on its sum of related lights. + """ + if (group := gateway.api.groups[group_id]) and not group.lights: return + first = True + for light_id in group.lights: + if ( + (light := gateway.api.lights.lights.get(light_id)) + and light.ZHATYPE == Light.ZHATYPE + and light.reachable + ): + group.update_color_state(light, update_all_attributes=first) + first = False + async_add_entities([DeconzGroup(group, gateway)]) - config_entry.async_on_unload( - gateway.api.groups.subscribe( - async_add_group, - EventType.ADDED, - ) + gateway.register_platform_add_device_callback( + async_add_group, + gateway.api.groups, ) - @callback - def async_load_groups() -> None: - """Load deCONZ groups.""" - for group_id in gateway.api.groups: - async_add_group(EventType.ADDED, group_id) - - config_entry.async_on_unload( - async_dispatcher_connect( - hass, - gateway.signal_reload_groups, - async_load_groups, - ) - ) - - async_load_groups() - class DeconzBaseLight(Generic[_L], DeconzDevice, LightEntity): """Representation of a deCONZ light.""" @@ -289,6 +279,16 @@ class DeconzLight(DeconzBaseLight[Light]): """Return the coldest color_temp that this light supports.""" return self._device.min_color_temp or super().min_mireds + @callback + def async_update_callback(self) -> None: + """Light state will also reflect in relevant groups.""" + super().async_update_callback() + + if self._device.reachable and "attr" not in self._device.changed_keys: + for group in self.gateway.api.groups.values(): + if self._device.resource_id in group.lights: + group.update_color_state(self._device) + class DeconzGroup(DeconzBaseLight[Group]): """Representation of a deCONZ group.""" diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 78ccae30441..cf4bd7f14f5 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -62,8 +62,26 @@ class DeconzLock(DeconzDevice, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._device.lock() + if isinstance(self._device, DoorLock): + await self.gateway.api.sensors.door_lock.set_config( + id=self._device.resource_id, + lock=True, + ) + else: + await self.gateway.api.lights.locks.set_state( + id=self._device.resource_id, + lock=True, + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - await self._device.unlock() + if isinstance(self._device, DoorLock): + await self.gateway.api.sensors.door_lock.set_config( + id=self._device.resource_id, + lock=False, + ) + else: + await self.gateway.api.lights.locks.set_state( + id=self._device.resource_id, + lock=False, + ) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 2a4a5ccf253..09dcc190a4f 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,7 +3,7 @@ "name": "deCONZ", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", - "requirements": ["pydeconz==92"], + "requirements": ["pydeconz==95"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index a7bb014d76a..81f3e434007 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -43,9 +43,9 @@ ENTITY_DESCRIPTIONS = { value_fn=lambda device: device.delay, suffix="Delay", update_key=PRESENCE_DELAY, - max_value=65535, - min_value=0, - step=1, + native_max_value=65535, + native_min_value=0, + native_step=1, entity_category=EntityCategory.CONFIG, ) ] @@ -107,11 +107,11 @@ class DeconzNumber(DeconzDevice, NumberEntity): super().async_update_callback() @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the value of the sensor property.""" return self.entity_description.value_fn(self._device) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set sensor config.""" data = {self.entity_description.key: int(value)} await self._device.set_config(**data) diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index dfbb6ae828b..236389cc100 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -43,4 +43,7 @@ class DeconzScene(DeconzSceneMixin, Scene): async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" - await self._device.recall() + await self.gateway.api.scenes.recall( + self._device.group_id, + self._device.id, + ) diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index 8427b6ce75d..d44bce01aad 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -59,11 +59,17 @@ class DeconzSiren(DeconzDevice, SirenEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" - data = {} if (duration := kwargs.get(ATTR_DURATION)) is not None: - data["duration"] = duration * 10 - await self._device.turn_on(**data) + duration *= 10 + await self.gateway.api.lights.sirens.set_state( + id=self._device.resource_id, + on=True, + duration=duration, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off siren.""" - await self._device.turn_off() + await self.gateway.api.lights.sirens.set_state( + id=self._device.resource_id, + on=False, + ) diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index d54ff1f36ba..b21ec929909 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -56,8 +56,14 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self._device.set_state(on=True) + await self.gateway.api.lights.lights.set_state( + id=self._device.resource_id, + on=True, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self._device.set_state(on=False) + await self.gateway.api.lights.lights.set_state( + id=self._device.resource_id, + on=False, + ) diff --git a/homeassistant/components/deconz/translations/ca.json b/homeassistant/components/deconz/translations/ca.json index f4659557503..c9f599429b4 100644 --- a/homeassistant/components/deconz/translations/ca.json +++ b/homeassistant/components/deconz/translations/ca.json @@ -57,7 +57,7 @@ "side_3": "cara 3", "side_4": "cara 4", "side_5": "cara 5", - "side_6": "cara 6", + "side_6": "Cara 6", "top_buttons": "Botons superiors", "turn_off": "Desactiva", "turn_on": "Activa" diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index e04385dcf3d..ee58a4f21c7 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -1,6 +1,7 @@ """Support for De Lijn (Flemish public transport) information.""" from __future__ import annotations +from datetime import datetime import logging from pydelijn.api import Passages @@ -111,7 +112,9 @@ class DeLijnPublicTransportSensor(SensorEntity): first = self.line.passages[0] if (first_passage := first["due_at_realtime"]) is None: first_passage = first["due_at_schedule"] - self._attr_native_value = first_passage + self._attr_native_value = datetime.strptime( + first_passage, "%Y-%m-%dT%H:%M:%S%z" + ) for key in AUTO_ATTRIBUTES: self._attr_extra_state_attributes[key] = first[key] diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 2f38d4d447d..359ed1635c5 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Deluge integration.""" from __future__ import annotations +from collections.abc import Mapping import socket from ssl import SSLError from typing import Any @@ -75,7 +76,7 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_user() diff --git a/homeassistant/components/deluge/translations/es.json b/homeassistant/components/deluge/translations/es.json index 7ba1b8c3bf9..1724791221f 100644 --- a/homeassistant/components/deluge/translations/es.json +++ b/homeassistant/components/deluge/translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "El servicio ya est\u00e1 configurado" + }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" @@ -12,7 +15,8 @@ "port": "Puerto", "username": "Usuario", "web_port": "Puerto web (para el servicio de visita)" - } + }, + "description": "Para poder usar esta integraci\u00f3n, debe habilitar la siguiente opci\u00f3n en la configuraci\u00f3n de diluvio: Daemon > Permitir controles remotos" } } } diff --git a/homeassistant/components/deluge/translations/sv.json b/homeassistant/components/deluge/translations/sv.json new file mode 100644 index 00000000000..1a65fe29a6f --- /dev/null +++ b/homeassistant/components/deluge/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "Port", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index f99693bfeb2..e389574c658 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure demo component.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -21,7 +23,9 @@ class DemoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -33,7 +37,7 @@ class DemoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 9a1ea6239ee..f867ed3faa4 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -1,6 +1,8 @@ """Demo platform for the cover component.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -108,42 +110,42 @@ class DemoCover(CoverEntity): ) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for cover.""" return self._unique_id @property - def name(self): + def name(self) -> str: """Return the name of the cover.""" return self._name @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed for a demo cover.""" return False @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of the cover.""" return self._position @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" return self._tilt_position @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._closed @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing.""" return self._is_closing @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening.""" return self._is_opening @@ -153,13 +155,13 @@ class DemoCover(CoverEntity): return self._device_class @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" if self._supported_features is not None: return self._supported_features return super().supported_features - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" if self._position == 0: return @@ -173,7 +175,7 @@ class DemoCover(CoverEntity): self._requested_closing = True self.async_write_ha_state() - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" if self._tilt_position in (0, None): return @@ -181,7 +183,7 @@ class DemoCover(CoverEntity): self._listen_cover_tilt() self._requested_closing_tilt = True - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" if self._position == 100: return @@ -195,7 +197,7 @@ class DemoCover(CoverEntity): self._requested_closing = False self.async_write_ha_state() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" if self._tilt_position in (100, None): return @@ -203,9 +205,9 @@ class DemoCover(CoverEntity): self._listen_cover_tilt() self._requested_closing_tilt = False - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - position = kwargs.get(ATTR_POSITION) + position: int = kwargs[ATTR_POSITION] self._set_position = round(position, -1) if self._position == position: return @@ -213,9 +215,9 @@ class DemoCover(CoverEntity): self._listen_cover() self._requested_closing = position < self._position - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover til to a specific position.""" - tilt_position = kwargs.get(ATTR_TILT_POSITION) + tilt_position: int = kwargs[ATTR_TILT_POSITION] self._set_tilt_position = round(tilt_position, -1) if self._tilt_position == tilt_position: return @@ -223,7 +225,7 @@ class DemoCover(CoverEntity): self._listen_cover_tilt() self._requested_closing_tilt = tilt_position < self._tilt_position - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._is_closing = False self._is_opening = False @@ -234,7 +236,7 @@ class DemoCover(CoverEntity): self._unsub_listener_cover = None self._set_position = None - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" if self._tilt_position is None: return diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 66440588f17..02623b7b644 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -1,6 +1,8 @@ """Demo fan platform that has a fake fan.""" from __future__ import annotations +from typing import Any + from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -122,7 +124,7 @@ class BaseDemoFan(FanEntity): self._direction = "forward" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id.""" return self._unique_id @@ -132,7 +134,7 @@ class BaseDemoFan(FanEntity): return self._name @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed for a demo fan.""" return False @@ -192,9 +194,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): def turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the entity.""" if preset_mode: @@ -206,7 +208,7 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): self.set_percentage(percentage) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn off the entity.""" self.set_percentage(0) @@ -262,9 +264,9 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the entity.""" if preset_mode: @@ -276,7 +278,7 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the entity.""" await self.async_oscillate(False) await self.async_set_percentage(0) diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 86188e8b935..d21d89f238b 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry @@ -66,26 +67,26 @@ class DemoLock(LockEntity): self._jam_on_operation = jam_on_operation @property - def is_locking(self): + def is_locking(self) -> bool: """Return true if lock is locking.""" return self._state == STATE_LOCKING @property - def is_unlocking(self): + def is_unlocking(self) -> bool: """Return true if lock is unlocking.""" return self._state == STATE_UNLOCKING @property - def is_jammed(self): + def is_jammed(self) -> bool: """Return true if lock is jammed.""" return self._state == STATE_JAMMED @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state == STATE_LOCKED - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" self._state = STATE_LOCKING self.async_write_ha_state() @@ -96,7 +97,7 @@ class DemoLock(LockEntity): self._state = STATE_LOCKED self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self._state = STATE_UNLOCKING self.async_write_ha_state() @@ -104,13 +105,13 @@ class DemoLock(LockEntity): self._state = STATE_UNLOCKED self.async_write_ha_state() - async def async_open(self, **kwargs): + async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" self._state = STATE_UNLOCKED self.async_write_ha_state() @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" if self._openable: return LockEntityFeature.OPEN diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 8660604af9e..02ab1a2a989 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -85,9 +85,9 @@ class DemoNumber(NumberEntity): state: float, icon: str, assumed: bool, - min_value: float | None = None, - max_value: float | None = None, - step: float | None = None, + native_min_value: float | None = None, + native_max_value: float | None = None, + native_step: float | None = None, mode: NumberMode = NumberMode.AUTO, ) -> None: """Initialize the Demo Number entity.""" @@ -95,15 +95,15 @@ class DemoNumber(NumberEntity): self._attr_icon = icon self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_unique_id = unique_id - self._attr_value = state + self._attr_native_value = state self._attr_mode = mode - if min_value is not None: - self._attr_min_value = min_value - if max_value is not None: - self._attr_max_value = max_value - if step is not None: - self._attr_step = step + if native_min_value is not None: + self._attr_native_min_value = native_min_value + if native_max_value is not None: + self._attr_native_max_value = native_max_value + if native_step is not None: + self._attr_native_step = native_step self._attr_device_info = DeviceInfo( identifiers={ @@ -113,7 +113,7 @@ class DemoNumber(NumberEntity): name=self.name, ) - async def async_set_value(self, value): + async def async_set_native_value(self, value): """Update the current value.""" - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 916083c5ad1..eed3e970b12 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -27,7 +27,14 @@ from homeassistant.components.weather import ( WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -77,6 +84,8 @@ def setup_platform( 1099, 0.5, TEMP_CELSIUS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, [ [ATTR_CONDITION_RAINY, 1, 22, 15, 60], [ATTR_CONDITION_RAINY, 5, 19, 8, 30], @@ -95,6 +104,8 @@ def setup_platform( 987, 4.8, TEMP_FAHRENHEIT, + PRESSURE_INHG, + SPEED_MILES_PER_HOUR, [ [ATTR_CONDITION_SNOWY, 2, -10, -15, 60], [ATTR_CONDITION_PARTLYCLOUDY, 1, -13, -14, 25], @@ -121,16 +132,20 @@ class DemoWeather(WeatherEntity): pressure, wind_speed, temperature_unit, + pressure_unit, + wind_speed_unit, forecast, ): """Initialize the Demo weather.""" self._name = name self._condition = condition - self._temperature = temperature - self._temperature_unit = temperature_unit + self._native_temperature = temperature + self._native_temperature_unit = temperature_unit self._humidity = humidity - self._pressure = pressure - self._wind_speed = wind_speed + self._native_pressure = pressure + self._native_pressure_unit = pressure_unit + self._native_wind_speed = wind_speed + self._native_wind_speed_unit = wind_speed_unit self._forecast = forecast @property @@ -144,14 +159,14 @@ class DemoWeather(WeatherEntity): return False @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" - return self._temperature + return self._native_temperature @property - def temperature_unit(self): + def native_temperature_unit(self): """Return the unit of measurement.""" - return self._temperature_unit + return self._native_temperature_unit @property def humidity(self): @@ -159,14 +174,24 @@ class DemoWeather(WeatherEntity): return self._humidity @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - return self._wind_speed + return self._native_wind_speed @property - def pressure(self): + def native_wind_speed_unit(self): + """Return the wind speed.""" + return self._native_wind_speed_unit + + @property + def native_pressure(self): """Return the pressure.""" - return self._pressure + return self._native_pressure + + @property + def native_pressure_unit(self): + """Return the pressure.""" + return self._native_pressure_unit @property def condition(self): diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 238c87bbf5e..fe6c05b3aca 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -48,7 +48,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict[str, Any] | None = None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -86,7 +88,7 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the Denon AVR flow.""" self.host = None self.serial_number = None @@ -105,7 +107,9 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def async_step_user(self, user_input: dict[str, Any] | None = None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 8d3102c441b..85e28c29d7c 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -204,12 +204,14 @@ class DenonDevice(MediaPlayerEntity): ) self._available = False except AvrCommandError as err: + available = False _LOGGER.error( "Command %s failed with error: %s", func.__name__, err, ) except DenonAvrError as err: + available = False _LOGGER.error( "Error %s occurred in method %s for Denon AVR receiver", err, diff --git a/homeassistant/components/denonavr/translations/es.json b/homeassistant/components/denonavr/translations/es.json index 3cfe69aeac4..f5993f46cf7 100644 --- a/homeassistant/components/denonavr/translations/es.json +++ b/homeassistant/components/denonavr/translations/es.json @@ -25,6 +25,9 @@ "user": { "data": { "host": "Direcci\u00f3n IP" + }, + "data_description": { + "host": "D\u00e9jelo en blanco para usar el descubrimiento autom\u00e1tico" } } } diff --git a/homeassistant/components/derivative/translations/es.json b/homeassistant/components/derivative/translations/es.json index e6df75ba4d6..e9b0919f06f 100644 --- a/homeassistant/components/derivative/translations/es.json +++ b/homeassistant/components/derivative/translations/es.json @@ -4,11 +4,17 @@ "user": { "data": { "name": "Nombre", + "round": "Precisi\u00f3n", "source": "Sensor de entrada", "time_window": "Ventana de tiempo", "unit_prefix": "Prefijo m\u00e9trico", "unit_time": "Unidad de tiempo" }, + "data_description": { + "round": "Controla el n\u00famero de d\u00edgitos decimales en la salida.", + "time_window": "Si se establece, el valor del sensor es un promedio m\u00f3vil ponderado en el tiempo de las derivadas dentro de esta ventana.", + "unit_prefix": "La salida se escalar\u00e1 seg\u00fan el prefijo m\u00e9trico seleccionado y la unidad de tiempo de la derivada." + }, "description": "Crea un sensor que ama la derivada de otro sensor.", "title": "A\u00f1ade sensor derivativo" } @@ -20,11 +26,14 @@ "data": { "name": "Nombre", "round": "Precisi\u00f3n", + "source": "Sensor de entrada", + "time_window": "Ventana de tiempo", "unit_prefix": "Prefijo m\u00e9trico", "unit_time": "Unidad de tiempo" }, "data_description": { "round": "Controla el n\u00famero de d\u00edgitos decimales en la salida.", + "time_window": "Si se establece, el valor del sensor es una media m\u00f3vil ponderada en el tiempo de las derivadas dentro de esta ventana.", "unit_prefix": "a salida se escalar\u00e1 seg\u00fan el prefijo m\u00e9trico y la unidad de tiempo de la derivada seleccionados." } } diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 61fd93354fe..0a1ec495e70 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -190,19 +190,23 @@ def _async_set_entity_device_automation_metadata( async def _async_get_device_automations_from_domain( - hass, domain, automation_type, device_ids, return_exceptions -): + hass: HomeAssistant, + domain: str, + automation_type: DeviceAutomationType, + device_ids: Iterable[str], + return_exceptions: bool, +) -> list[list[dict[str, Any]] | Exception]: """List device automations.""" try: platform = await async_get_device_automation_platform( hass, domain, automation_type ) except InvalidDeviceAutomationConfig: - return {} + return [] function_name = automation_type.value.get_automations_func - return await asyncio.gather( + return await asyncio.gather( # type: ignore[no-any-return] *( getattr(platform, function_name)(hass, device_id) for device_id in device_ids diff --git a/homeassistant/components/device_automation/action.py b/homeassistant/components/device_automation/action.py index 5737fbc5bf3..081b6bb283a 100644 --- a/homeassistant/components/device_automation/action.py +++ b/homeassistant/components/device_automation/action.py @@ -1,7 +1,6 @@ """Device action validator.""" from __future__ import annotations -from collections.abc import Awaitable from typing import Any, Protocol, cast import voluptuous as vol @@ -36,14 +35,14 @@ class DeviceAutomationActionProtocol(Protocol): ) -> None: """Execute a device action.""" - def async_get_action_capabilities( + async def async_get_action_capabilities( self, hass: HomeAssistant, config: ConfigType - ) -> dict[str, vol.Schema] | Awaitable[dict[str, vol.Schema]]: + ) -> dict[str, vol.Schema]: """List action capabilities.""" - def async_get_actions( + async def async_get_actions( self, hass: HomeAssistant, device_id: str - ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]: + ) -> list[dict[str, Any]]: """List actions.""" diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 1f1f8e94832..d656908f4be 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -1,7 +1,6 @@ """Validate device conditions.""" from __future__ import annotations -from collections.abc import Awaitable from typing import TYPE_CHECKING, Any, Protocol, cast import voluptuous as vol @@ -36,14 +35,14 @@ class DeviceAutomationConditionProtocol(Protocol): ) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" - def async_get_condition_capabilities( + async def async_get_condition_capabilities( self, hass: HomeAssistant, config: ConfigType - ) -> dict[str, vol.Schema] | Awaitable[dict[str, vol.Schema]]: + ) -> dict[str, vol.Schema]: """List condition capabilities.""" - def async_get_conditions( + async def async_get_conditions( self, hass: HomeAssistant, device_id: str - ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]: + ) -> list[dict[str, Any]]: """List conditions.""" diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index c5f42b3e813..eb39ec383af 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -1,7 +1,6 @@ """Offer device oriented automation.""" from __future__ import annotations -from collections.abc import Awaitable from typing import Any, Protocol, cast import voluptuous as vol @@ -46,14 +45,14 @@ class DeviceAutomationTriggerProtocol(Protocol): ) -> CALLBACK_TYPE: """Attach a trigger.""" - def async_get_trigger_capabilities( + async def async_get_trigger_capabilities( self, hass: HomeAssistant, config: ConfigType - ) -> dict[str, vol.Schema] | Awaitable[dict[str, vol.Schema]]: + ) -> dict[str, vol.Schema]: """List trigger capabilities.""" - def async_get_triggers( + async def async_get_triggers( self, hass: HomeAssistant, device_id: str - ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]: + ) -> list[dict[str, Any]]: """List triggers.""" diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index e0e49197f45..aef9592d2e9 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the devolo home control integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -67,14 +68,14 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="zeroconf_confirm", errors={"base": "invalid_auth"} ) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauthentication.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - self._url = user_input[CONF_MYDEVOLO] + self._url = entry_data[CONF_MYDEVOLO] self.data_schema = { - vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): str, + vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, } return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/devolo_home_control/translations/sv.json b/homeassistant/components/devolo_home_control/translations/sv.json index 4479e25b250..13e09780b40 100644 --- a/homeassistant/components/devolo_home_control/translations/sv.json +++ b/homeassistant/components/devolo_home_control/translations/sv.json @@ -7,6 +7,11 @@ "password": "L\u00f6senord", "username": "E-postadress / devolo-ID" } + }, + "zeroconf_confirm": { + "data": { + "username": "E-postadress / devolo-id" + } } } } diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 063d14549db..6ccb09881af 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Dexcom integration.""" +from __future__ import annotations + from pydexcom import AccountError, Dexcom, SessionError import voluptuous as vol @@ -53,7 +55,9 @@ class DexcomConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> DexcomOptionsFlowHandler: """Get the options flow for this handler.""" return DexcomOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/dexcom/translations/sv.json b/homeassistant/components/dexcom/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/dexcom/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/translations/sv.json b/homeassistant/components/dialogflow/translations/sv.json index 9642b4b7bec..ebae7e612d0 100644 --- a/homeassistant/components/dialogflow/translations/sv.json +++ b/homeassistant/components/dialogflow/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "webhook_not_internet_accessible": "Din Home Assistant instans m\u00e5ste kunna n\u00e5s fr\u00e5n Internet f\u00f6r att ta emot webhook meddelanden" + }, "create_entry": { "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera [webhook funktionen i Dialogflow]({dialogflow_url}).\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information." }, diff --git a/homeassistant/components/directv/translations/bg.json b/homeassistant/components/directv/translations/bg.json index ffb69776060..b43da9ecb18 100644 --- a/homeassistant/components/directv/translations/bg.json +++ b/homeassistant/components/directv/translations/bg.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/discord/config_flow.py b/homeassistant/components/discord/config_flow.py index bce27feced3..b28c55b022f 100644 --- a/homeassistant/components/discord/config_flow.py +++ b/homeassistant/components/discord/config_flow.py @@ -1,7 +1,9 @@ """Config flow for Discord integration.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from aiohttp.client_exceptions import ClientConnectorError import nextcord @@ -21,13 +23,9 @@ CONFIG_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) class DiscordFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Discord.""" - async def async_step_reauth(self, user_input: dict | None = None) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" - if user_input is not None: - return await self.async_step_reauth_confirm() - - self._set_confirm_only() - return self.async_show_form(step_id="reauth") + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, str] | None = None diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 299919472cf..d97ce7042bc 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -27,6 +27,7 @@ ATTR_EMBED_FIELDS = "fields" ATTR_EMBED_FOOTER = "footer" ATTR_EMBED_TITLE = "title" ATTR_EMBED_THUMBNAIL = "thumbnail" +ATTR_EMBED_IMAGE = "image" ATTR_EMBED_URL = "url" ATTR_IMAGES = "images" @@ -94,6 +95,8 @@ class DiscordNotificationService(BaseNotificationService): embed.set_author(**embedding[ATTR_EMBED_AUTHOR]) if ATTR_EMBED_THUMBNAIL in embedding: embed.set_thumbnail(**embedding[ATTR_EMBED_THUMBNAIL]) + if ATTR_EMBED_IMAGE in embedding: + embed.set_image(**embedding[ATTR_EMBED_IMAGE]) embeds.append(embed) if ATTR_IMAGES in data: diff --git a/homeassistant/components/discord/translations/es.json b/homeassistant/components/discord/translations/es.json index 768afb877f3..f4f7bd49fc5 100644 --- a/homeassistant/components/discord/translations/es.json +++ b/homeassistant/components/discord/translations/es.json @@ -1,10 +1,13 @@ { "config": { "abort": { + "already_configured": "El servicio ya est\u00e1 configurado", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, "error": { - "cannot_connect": "Fall\u00f3 la conexi\u00f3n" + "cannot_connect": "Fall\u00f3 la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { @@ -16,7 +19,8 @@ "user": { "data": { "api_token": "Token API" - } + }, + "description": "Consulte la documentaci\u00f3n sobre c\u00f3mo obtener su clave de bot de Discord. \n\n {url}" } } } diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index a0ffbf235ab..3c3538c1ca0 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -63,7 +63,6 @@ SERVICE_HANDLERS = { "openhome": ServiceDetails("media_player", "openhome"), "bose_soundtouch": ServiceDetails("media_player", "soundtouch"), "bluesound": ServiceDetails("media_player", "bluesound"), - "lg_smart_device": ServiceDetails("media_player", "lg_soundbar"), } OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} @@ -98,6 +97,7 @@ MIGRATED_SERVICE_HANDLERS = [ SERVICE_YEELIGHT, SERVICE_SABNZBD, "nanoleaf_aurora", + "lg_smart_device", ] DEFAULT_ENABLED = ( diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index cc72f5d4778..7e03d34e900 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.31.1"], + "requirements": ["async-upnp-client==0.31.2"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 590d1b8370a..a07f33d09dd 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Server", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dms", - "requirements": ["async-upnp-client==0.31.1"], + "requirements": ["async-upnp-client==0.31.2"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dms/translations/es.json b/homeassistant/components/dlna_dms/translations/es.json index 1ce13967ea5..f9d08f90451 100644 --- a/homeassistant/components/dlna_dms/translations/es.json +++ b/homeassistant/components/dlna_dms/translations/es.json @@ -1,16 +1,23 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El proceso de configuraci\u00f3n ya est\u00e1 en curso", "bad_ssdp": "Falta un valor necesario en los datos SSDP", - "no_devices_found": "No se han encontrado dispositivos en la red" + "no_devices_found": "No se han encontrado dispositivos en la red", + "not_dms": "El dispositivo no es un servidor multimedia compatible" }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u00bfQuiere empezar a configurar?" + }, "user": { "data": { "host": "Host" }, - "description": "Escoge un dispositivo a configurar" + "description": "Escoge un dispositivo a configurar", + "title": "Dispositivos DLNA DMA descubiertos" } } } diff --git a/homeassistant/components/dnsip/translations/he.json b/homeassistant/components/dnsip/translations/he.json new file mode 100644 index 00000000000..13f865d61a2 --- /dev/null +++ b/homeassistant/components/dnsip/translations/he.json @@ -0,0 +1,29 @@ +{ + "config": { + "error": { + "invalid_hostname": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea" + }, + "step": { + "user": { + "data": { + "hostname": "\u05e9\u05dd \u05d4\u05de\u05d0\u05e8\u05d7 \u05e9\u05e2\u05d1\u05d5\u05e8\u05d5 \u05e0\u05d9\u05ea\u05df \u05dc\u05d1\u05e6\u05e2 \u05e9\u05d0\u05d9\u05dc\u05ea\u05ea DNS", + "resolver": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV4", + "resolver_ipv6": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV6" + } + } + } + }, + "options": { + "error": { + "invalid_resolver": "\u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea \u05dc\u05de\u05e4\u05e2\u05e0\u05d7" + }, + "step": { + "init": { + "data": { + "resolver": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV4", + "resolver_ipv6": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV6" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index cc882b0ed50..678340c0259 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -1,4 +1,6 @@ """Config flow for DoorBird integration.""" +from __future__ import annotations + from http import HTTPStatus from ipaddress import ip_address import logging @@ -144,7 +146,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/doorbird/translations/es.json b/homeassistant/components/doorbird/translations/es.json index 68de2419d2a..355a48e9191 100644 --- a/homeassistant/components/doorbird/translations/es.json +++ b/homeassistant/components/doorbird/translations/es.json @@ -27,6 +27,9 @@ "init": { "data": { "events": "Lista de eventos separados por comas." + }, + "data_description": { + "events": "A\u00f1ade un nombre de evento separado por comas para cada evento que desee rastrear. Despu\u00e9s de ingresarlos aqu\u00ed, use la aplicaci\u00f3n DoorBird para asignarlos a un evento espec\u00edfico. \n\n Ejemplo: alguien_puls\u00f3_el_bot\u00f3n, movimiento" } } } diff --git a/homeassistant/components/doorbird/translations/sv.json b/homeassistant/components/doorbird/translations/sv.json index b2a809a576e..56c44dee6fb 100644 --- a/homeassistant/components/doorbird/translations/sv.json +++ b/homeassistant/components/doorbird/translations/sv.json @@ -8,6 +8,7 @@ "data": { "host": "V\u00e4rd (IP-adress)", "name": "Enhetsnamn", + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 43c0e66e945..9f08e812e04 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -245,10 +245,28 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + DSMRSensorEntityDescription( + key=obis_references.BELGIUM_MAX_POWER_PER_PHASE, + name="Max power per phase", + dsmr_versions={"5B"}, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE, + name="Max current per phase", + dsmr_versions={"5B"}, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_IMPORTED_TOTAL, name="Energy Consumption (total)", - dsmr_versions={"5", "5B", "5L", "5S", "Q3D"}, + dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 930ced4ff54..e5c38996a89 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -1,5 +1,7 @@ """Support for the Dynalite channels as covers.""" +from typing import Any + from homeassistant.components.cover import DEVICE_CLASSES, CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -60,19 +62,19 @@ class DynaliteCover(DynaliteBase, CoverEntity): """Return true if cover is closed.""" return self._device.is_closed - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.async_open_cover(**kwargs) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.async_close_cover(**kwargs) - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover position.""" await self._device.async_set_cover_position(**kwargs) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._device.async_stop_cover(**kwargs) @@ -85,18 +87,18 @@ class DynaliteCoverWithTilt(DynaliteCover): """Return the current tilt position.""" return self._device.current_cover_tilt_position - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" await self._device.async_open_cover_tilt(**kwargs) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" await self._device.async_close_cover_tilt(**kwargs) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Set the cover tilt position.""" await self._device.async_set_cover_tilt_position(**kwargs) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self._device.async_stop_cover_tilt(**kwargs) diff --git a/homeassistant/components/econet/translations/bg.json b/homeassistant/components/econet/translations/bg.json index cef3726d759..3468d506903 100644 --- a/homeassistant/components/econet/translations/bg.json +++ b/homeassistant/components/econet/translations/bg.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/efergy/config_flow.py b/homeassistant/components/efergy/config_flow.py index 5ff6e9ba9f2..b2f2a368a9e 100644 --- a/homeassistant/components/efergy/config_flow.py +++ b/homeassistant/components/efergy/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Efergy integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pyefergy import Efergy, exceptions @@ -52,7 +53,7 @@ class EfergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_user() diff --git a/homeassistant/components/efergy/translations/sv.json b/homeassistant/components/efergy/translations/sv.json new file mode 100644 index 00000000000..b163c1a520b --- /dev/null +++ b/homeassistant/components/efergy/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "user": { + "data": { + "api_key": "API nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index 2e2abe1fc87..de179c248bb 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -63,6 +63,7 @@ def setup_platform( class EgardiaAlarm(alarm.AlarmControlPanelEntity): """Representation of a Egardia alarm.""" + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -72,31 +73,20 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010 ): """Initialize the Egardia alarm.""" - self._name = name + self._attr_name = name self._egardiasystem = egardiasystem - self._status = None self._rs_enabled = rs_enabled self._rs_codes = rs_codes self._rs_port = rs_port - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add Egardiaserver callback if enabled.""" if self._rs_enabled: _LOGGER.debug("Registering callback to Egardiaserver") self.hass.data[EGARDIA_SERVER].register_callback(self.handle_status_event) @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._status - - @property - def should_poll(self): + def should_poll(self) -> bool: """Poll if no report server is enabled.""" if not self._rs_enabled: return True @@ -130,16 +120,16 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): _LOGGER.debug("Not ignoring status %s", status) newstatus = STATES.get(status.upper()) _LOGGER.debug("newstatus %s", newstatus) - self._status = newstatus + self._attr_state = newstatus else: _LOGGER.error("Ignoring status") - def update(self): + def update(self) -> None: """Update the alarm status.""" status = self._egardiasystem.getstate() self.parsestatus(status) - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" try: self._egardiasystem.alarm_disarm() @@ -149,7 +139,7 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): err, ) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" try: self._egardiasystem.alarm_arm_home() @@ -160,7 +150,7 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): err, ) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" try: self._egardiasystem.alarm_arm_away() diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index e986a7f6d60..5cd7bec9244 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,34 +1,38 @@ """Support for Eight smart mattress covers and mattresses.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from pyeight.eight import EightSleep +from pyeight.exceptions import RequestError from pyeight.user import EightUser import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import UNDEFINED, ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import ( - ATTR_HEAT_DURATION, - ATTR_TARGET_HEAT, - DATA_API, - DATA_HEAT, - DATA_USER, - DOMAIN, - NAME_MAP, - SERVICE_HEAT_SET, -) +from .const import DOMAIN, NAME_MAP _LOGGER = logging.getLogger(__name__) @@ -37,17 +41,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] HEAT_SCAN_INTERVAL = timedelta(seconds=60) USER_SCAN_INTERVAL = timedelta(seconds=300) -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) -VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) - -SERVICE_EIGHT_SCHEMA = vol.Schema( - { - ATTR_ENTITY_ID: cv.entity_ids, - ATTR_TARGET_HEAT: VALID_TARGET_HEAT, - ATTR_HEAT_DURATION: VALID_DURATION, - } -) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -61,32 +54,55 @@ CONFIG_SCHEMA = vol.Schema( ) +@dataclass +class EightSleepConfigEntryData: + """Data used for all entities for a given config entry.""" + + api: EightSleep + heat_coordinator: DataUpdateCoordinator + user_coordinator: DataUpdateCoordinator + + def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: """Get the device's unique ID.""" - unique_id = eight.deviceid + unique_id = eight.device_id + assert unique_id if user_obj: - unique_id = f"{unique_id}.{user_obj.userid}.{user_obj.side}" + unique_id = f"{unique_id}.{user_obj.user_id}.{user_obj.side}" return unique_id async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Eight Sleep component.""" + """Old set up method for the Eight Sleep component.""" + if DOMAIN in config: + _LOGGER.warning( + "Your Eight Sleep configuration has been imported into the UI; " + "please remove it from configuration.yaml as support for it " + "will be removed in a future release" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) - if DOMAIN not in config: - return True + return True - conf = config[DOMAIN] - user = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Eight Sleep config entry.""" eight = EightSleep( - user, password, hass.config.time_zone, async_get_clientsession(hass) + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + hass.config.time_zone, + async_get_clientsession(hass), ) - hass.data.setdefault(DOMAIN, {}) - # Authenticate, build sensors - success = await eight.start() + try: + success = await eight.start() + except RequestError as err: + raise ConfigEntryNotReady from err if not success: # Authentication failed, cannot continue return False @@ -112,46 +128,60 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # No users, cannot continue return False - hass.data[DOMAIN] = { - DATA_API: eight, - DATA_HEAT: heat_coordinator, - DATA_USER: user_coordinator, + dev_reg = async_get(hass) + assert eight.device_data + device_data = { + ATTR_MANUFACTURER: "Eight Sleep", + ATTR_MODEL: eight.device_data.get("modelString", UNDEFINED), + ATTR_HW_VERSION: eight.device_data.get("sensorInfo", {}).get( + "hwRevision", UNDEFINED + ), + ATTR_SW_VERSION: eight.device_data.get("firmwareVersion", UNDEFINED), } - - for platform in PLATFORMS: - hass.async_create_task( - discovery.async_load_platform(hass, platform, DOMAIN, {}, config) + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _get_device_unique_id(eight))}, + name=f"{entry.data[CONF_USERNAME]}'s Eight Sleep", + **device_data, + ) + for user in eight.users.values(): + assert user.user_profile + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _get_device_unique_id(eight, user))}, + name=f"{user.user_profile['firstName']}'s Eight Sleep Side", + via_device=(DOMAIN, _get_device_unique_id(eight)), + **device_data, ) - async def async_service_handler(service: ServiceCall) -> None: - """Handle eight sleep service calls.""" - params = service.data.copy() - - sensor = params.pop(ATTR_ENTITY_ID, None) - target = params.pop(ATTR_TARGET_HEAT, None) - duration = params.pop(ATTR_HEAT_DURATION, 0) - - for sens in sensor: - side = sens.split("_")[1] - userid = eight.fetch_userid(side) - usrobj = eight.users[userid] - await usrobj.set_heating_level(target, duration) - - await heat_coordinator.async_request_refresh() - - # Register services - hass.services.async_register( - DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData( + eight, heat_coordinator, user_coordinator ) + hass.config_entries.async_setup_platforms(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): + # stop the API before unloading everything + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + await config_entry_data.api.stop() + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): """The base Eight Sleep entity class.""" def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str | None, @@ -159,18 +189,35 @@ class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): ) -> None: """Initialize the data object.""" super().__init__(coordinator) + self._config_entry = entry self._eight = eight self._user_id = user_id self._sensor = sensor self._user_obj: EightUser | None = None - if self._user_id: + if user_id: self._user_obj = self._eight.users[user_id] mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title()) if self._user_obj is not None: - mapped_name = f"{self._user_obj.side.title()} {mapped_name}" + assert self._user_obj.user_profile + name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}" + self._attr_name = name + else: + self._attr_name = f"Eight Sleep {mapped_name}" + unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" + self._attr_unique_id = unique_id + identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))} + self._attr_device_info = DeviceInfo(identifiers=identifiers) - self._attr_name = f"Eight {mapped_name}" - self._attr_unique_id = ( - f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" - ) + async def async_heat_set(self, target: int, duration: int) -> None: + """Handle eight sleep service calls.""" + if self._user_obj is None: + raise HomeAssistantError( + "This entity does not support the heat set service." + ) + + await self._user_obj.set_heating_level(target, duration) + config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][ + self._config_entry.entry_id + ] + await config_entry_data.heat_coordinator.async_request_refresh() diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index 868a5177cfe..7ad1b882008 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -9,37 +9,30 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import EightSleepBaseEntity -from .const import DATA_API, DATA_HEAT, DOMAIN +from . import EightSleepBaseEntity, EightSleepConfigEntryData +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +BINARY_SENSORS = ["bed_presence"] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the eight sleep binary sensor.""" - if discovery_info is None: - return - - eight: EightSleep = hass.data[DOMAIN][DATA_API] - heat_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_HEAT] - - entities = [] - for user in eight.users.values(): - entities.append( - EightHeatSensor(heat_coordinator, eight, user.userid, "bed_presence") - ) - - async_add_entities(entities) + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + eight = config_entry_data.api + heat_coordinator = config_entry_data.heat_coordinator + async_add_entities( + EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor) + for user in eight.users.values() + for binary_sensor in BINARY_SENSORS + ) class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): @@ -49,13 +42,14 @@ class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str | None, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj _LOGGER.debug( "Presence Sensor: %s, Side: %s, User: %s", diff --git a/homeassistant/components/eight_sleep/config_flow.py b/homeassistant/components/eight_sleep/config_flow.py new file mode 100644 index 00000000000..504fbeb2817 --- /dev/null +++ b/homeassistant/components/eight_sleep/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for Eight Sleep integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyeight.eight import EightSleep +from pyeight.exceptions import RequestError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Eight Sleep.""" + + VERSION = 1 + + async def _validate_data(self, config: dict[str, str]) -> str | None: + """Validate input data and return any error.""" + await self.async_set_unique_id(config[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + eight = EightSleep( + config[CONF_USERNAME], + config[CONF_PASSWORD], + self.hass.config.time_zone, + client_session=async_get_clientsession(self.hass), + ) + + try: + await eight.fetch_token() + except RequestError as err: + return str(err) + + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + if (err := await self._validate_data(user_input)) is not None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + description_placeholders={"error": err}, + ) + + return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + + async def async_step_import(self, import_config: dict) -> FlowResult: + """Handle import.""" + if (err := await self._validate_data(import_config)) is not None: + _LOGGER.error("Unable to import configuration.yaml configuration: %s", err) + return self.async_abort( + reason="cannot_connect", description_placeholders={"error": err} + ) + + return self.async_create_entry( + title=import_config[CONF_USERNAME], data=import_config + ) diff --git a/homeassistant/components/eight_sleep/const.py b/homeassistant/components/eight_sleep/const.py index 42a9eea590e..23689066665 100644 --- a/homeassistant/components/eight_sleep/const.py +++ b/homeassistant/components/eight_sleep/const.py @@ -1,7 +1,4 @@ """Eight Sleep constants.""" -DATA_HEAT = "heat" -DATA_USER = "user" -DATA_API = "api" DOMAIN = "eight_sleep" HEAT_ENTITY = "heat" @@ -15,5 +12,5 @@ NAME_MAP = { SERVICE_HEAT_SET = "heat_set" -ATTR_TARGET_HEAT = "target" -ATTR_HEAT_DURATION = "duration" +ATTR_TARGET = "target" +ATTR_DURATION = "duration" diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index e4c5a1e0029..c1833b222df 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -2,8 +2,9 @@ "domain": "eight_sleep", "name": "Eight Sleep", "documentation": "https://www.home-assistant.io/integrations/eight_sleep", - "requirements": ["pyeight==0.2.0"], + "requirements": ["pyeight==0.3.0"], "codeowners": ["@mezz64", "@raman325"], "iot_class": "cloud_polling", - "loggers": ["pyeight"] + "loggers": ["pyeight"], + "config_flow": true } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index b405617e276..b184cd2496f 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -5,16 +5,17 @@ import logging from typing import Any from pyeight.eight import EightSleep +import voluptuous as vol from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers import entity_platform as ep from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import EightSleepBaseEntity -from .const import DATA_API, DATA_HEAT, DATA_USER, DOMAIN +from . import EightSleepBaseEntity, EightSleepConfigEntryData +from .const import ATTR_DURATION, ATTR_TARGET, DOMAIN, SERVICE_HEAT_SET ATTR_ROOM_TEMP = "Room Temperature" ATTR_AVG_ROOM_TEMP = "Average Room Temperature" @@ -53,37 +54,50 @@ EIGHT_USER_SENSORS = [ EIGHT_HEAT_SENSORS = ["bed_state"] EIGHT_ROOM_SENSORS = ["room_temperature"] +VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) +VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +SERVICE_EIGHT_SCHEMA = { + ATTR_TARGET: VALID_TARGET_HEAT, + ATTR_DURATION: VALID_DURATION, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: ep.AddEntitiesCallback ) -> None: """Set up the eight sleep sensors.""" - if discovery_info is None: - return - - eight: EightSleep = hass.data[DOMAIN][DATA_API] - heat_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_HEAT] - user_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_USER] + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + eight = config_entry_data.api + heat_coordinator = config_entry_data.heat_coordinator + user_coordinator = config_entry_data.user_coordinator all_sensors: list[SensorEntity] = [] for obj in eight.users.values(): - for sensor in EIGHT_USER_SENSORS: - all_sensors.append( - EightUserSensor(user_coordinator, eight, obj.userid, sensor) - ) - for sensor in EIGHT_HEAT_SENSORS: - all_sensors.append( - EightHeatSensor(heat_coordinator, eight, obj.userid, sensor) - ) - for sensor in EIGHT_ROOM_SENSORS: - all_sensors.append(EightRoomSensor(user_coordinator, eight, sensor)) + all_sensors.extend( + EightUserSensor(entry, user_coordinator, eight, obj.user_id, sensor) + for sensor in EIGHT_USER_SENSORS + ) + all_sensors.extend( + EightHeatSensor(entry, heat_coordinator, eight, obj.user_id, sensor) + for sensor in EIGHT_HEAT_SENSORS + ) + + all_sensors.extend( + EightRoomSensor(entry, user_coordinator, eight, sensor) + for sensor in EIGHT_ROOM_SENSORS + ) async_add_entities(all_sensors) + platform = ep.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_HEAT_SET, + SERVICE_EIGHT_SCHEMA, + "async_heat_set", + ) + class EightHeatSensor(EightSleepBaseEntity, SensorEntity): """Representation of an eight sleep heat-based sensor.""" @@ -92,13 +106,14 @@ class EightHeatSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj _LOGGER.debug( @@ -109,7 +124,7 @@ class EightHeatSensor(EightSleepBaseEntity, SensorEntity): ) @property - def native_value(self) -> int: + def native_value(self) -> int | None: """Return the state of the sensor.""" assert self._user_obj return self._user_obj.heating_level @@ -147,13 +162,14 @@ class EightUserSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj if self._sensor == "bed_temperature": @@ -260,14 +276,15 @@ class EightRoomSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry, coordinator: DataUpdateCoordinator, eight: EightSleep, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, None, sensor) + super().__init__(entry, coordinator, eight, None, sensor) @property def native_value(self) -> int | float | None: """Return the state of the sensor.""" - return self._eight.room_temperature() + return self._eight.room_temperature diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml index de864afc160..39b960a6f7c 100644 --- a/homeassistant/components/eight_sleep/services.yaml +++ b/homeassistant/components/eight_sleep/services.yaml @@ -1,6 +1,10 @@ heat_set: name: Heat set description: Set heating/cooling level for eight sleep. + target: + entity: + integration: eight_sleep + domain: sensor fields: duration: name: Duration @@ -11,14 +15,6 @@ heat_set: min: 0 max: 28800 unit_of_measurement: seconds - entity_id: - name: Entity - description: Entity id of the bed state to adjust. - required: true - selector: - entity: - integration: eight_sleep - domain: sensor target: name: Target description: Target cooling/heating level from -100 to 100. diff --git a/homeassistant/components/eight_sleep/strings.json b/homeassistant/components/eight_sleep/strings.json new file mode 100644 index 00000000000..21accc53a06 --- /dev/null +++ b/homeassistant/components/eight_sleep/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" + } + } +} diff --git a/homeassistant/components/eight_sleep/translations/ca.json b/homeassistant/components/eight_sleep/translations/ca.json new file mode 100644 index 00000000000..0abb283ce08 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "No es pot connectar amb el n\u00favol d'Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "No es pot connectar amb el n\u00favol d'Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/cs.json b/homeassistant/components/eight_sleep/translations/cs.json new file mode 100644 index 00000000000..86766978310 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "cannot_connect": "Nemohu se p\u0159ipojit k Eight Sleep cloudu: {error}" + }, + "error": { + "cannot_connect": "Nemohu se p\u0159ipojit k Eight Sleep cloudu: {error}" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/de.json b/homeassistant/components/eight_sleep/translations/de.json new file mode 100644 index 00000000000..0d2dbadfd51 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Es kann keine Verbindung zur Eight Sleep Cloud hergestellt werden: {error}" + }, + "error": { + "cannot_connect": "Es kann keine Verbindung zur Eight Sleep Cloud hergestellt werden: {error}" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/el.json b/homeassistant/components/eight_sleep/translations/el.json new file mode 100644 index 00000000000..2bdf1e689ff --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/el.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "cannot_connect": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03c3\u03c4\u03bf \u03c3\u03cd\u03bd\u03bd\u03b5\u03c6\u03bf Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03c3\u03c4\u03bf \u03c3\u03cd\u03bd\u03bd\u03b5\u03c6\u03bf Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/en.json b/homeassistant/components/eight_sleep/translations/en.json similarity index 60% rename from homeassistant/components/ialarm_xr/translations/en.json rename to homeassistant/components/eight_sleep/translations/en.json index be59a5a1dc4..29926915fbb 100644 --- a/homeassistant/components/ialarm_xr/translations/en.json +++ b/homeassistant/components/eight_sleep/translations/en.json @@ -1,19 +1,16 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" }, "error": { - "cannot_connect": "Failed to connect", - "timeout": "Timeout establishing connection", - "unknown": "Unexpected error" + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" }, "step": { "user": { "data": { - "host": "Host", "password": "Password", - "port": "Port", "username": "Username" } } diff --git a/homeassistant/components/eight_sleep/translations/es.json b/homeassistant/components/eight_sleep/translations/es.json new file mode 100644 index 00000000000..5936b1046b7 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se puede conectar a la nube de Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "No se puede conectar a la nube de Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/et.json b/homeassistant/components/eight_sleep/translations/et.json new file mode 100644 index 00000000000..05ba852c7e4 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/et.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "Ei saa \u00fchendust Eight Sleep pilvega: {error}" + }, + "error": { + "cannot_connect": "Ei saa \u00fchendust Eight Sleep pilvega: {error}" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/fr.json b/homeassistant/components/eight_sleep/translations/fr.json new file mode 100644 index 00000000000..ae9902a5d7d --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "Impossible de se connecter au cloud Eight Sleep\u00a0: {error}" + }, + "error": { + "cannot_connect": "Impossible de se connecter au cloud Eight Sleep\u00a0: {error}" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/hu.json b/homeassistant/components/eight_sleep/translations/hu.json new file mode 100644 index 00000000000..61c0ce0f92d --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Nem lehet csatlakozni az Eight Sleep felh\u0151h\u00f6z: {error}" + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni az Eight Sleep felh\u0151h\u00f6z: {error}" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/id.json b/homeassistant/components/eight_sleep/translations/id.json new file mode 100644 index 00000000000..4bc4e32e7ee --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/id.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Tidak dapat terhubung ke cloud Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "Tidak dapat terhubung ke cloud Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/it.json b/homeassistant/components/eight_sleep/translations/it.json new file mode 100644 index 00000000000..c849a5f3504 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi al cloud Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "Impossibile connettersi al cloud Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/ja.json b/homeassistant/components/eight_sleep/translations/ja.json new file mode 100644 index 00000000000..c91a9c9dd2c --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "Eight Sleep\u30af\u30e9\u30a6\u30c9\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093: {error}" + }, + "error": { + "cannot_connect": "Eight Sleep\u30af\u30e9\u30a6\u30c9\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093: {error}" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/nl.json b/homeassistant/components/eight_sleep/translations/nl.json new file mode 100644 index 00000000000..afd044ded29 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan niet verbinden met Eight Sleep cloud: {error}" + }, + "error": { + "cannot_connect": "Kan niet verbinden met Eight Sleep cloud: {error}" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/no.json b/homeassistant/components/eight_sleep/translations/no.json new file mode 100644 index 00000000000..715f27e2075 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Kan ikke koble til Eight Sleep-skyen: {error}" + }, + "error": { + "cannot_connect": "Kan ikke koble til Eight Sleep-skyen: {error}" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/pl.json b/homeassistant/components/eight_sleep/translations/pl.json new file mode 100644 index 00000000000..a0f0443cabb --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z chmur\u0105 Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z chmur\u0105 Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/pt-BR.json b/homeassistant/components/eight_sleep/translations/pt-BR.json new file mode 100644 index 00000000000..acb63e80352 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/pt-BR.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar \u00e0 nuvem Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar \u00e0 nuvem Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Usu\u00e1rio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/sv.json b/homeassistant/components/eight_sleep/translations/sv.json new file mode 100644 index 00000000000..af5c7e7fe8d --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/tr.json b/homeassistant/components/eight_sleep/translations/tr.json new file mode 100644 index 00000000000..19e958601cb --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/tr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Sekiz Uyku bulutuna ba\u011flan\u0131lam\u0131yor: {error}" + }, + "error": { + "cannot_connect": "Sekiz Uyku bulutuna ba\u011flan\u0131lam\u0131yor: {error}" + }, + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/uk.json b/homeassistant/components/eight_sleep/translations/uk.json new file mode 100644 index 00000000000..4dea8ca0857 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/uk.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u0454\u0442\u044c\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e \u0445\u043c\u0430\u0440\u0438 Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u0454\u0442\u044c\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e \u0445\u043c\u0430\u0440\u0438 Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/zh-Hant.json b/homeassistant/components/eight_sleep/translations/zh-Hant.json new file mode 100644 index 00000000000..cda9624d5f0 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Eight Sleep cloud\uff1a{error}" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Eight Sleep cloud\uff1a{error}" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index f2cfc2b8673..c846c42c653 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -9,6 +9,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -49,5 +50,7 @@ class ElgatoIdentifyButton(ElgatoEntity, ButtonEntity): """Identify the light, will make it blink.""" try: await self.client.identify() - except ElgatoError: - _LOGGER.exception("An error occurred while identifying the Elgato Light") + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while identifying the Elgato Light" + ) from error diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 7abf570ba3e..9e63df0a503 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -6,7 +6,7 @@ from typing import Any from elgato import Elgato, ElgatoError import voluptuous as vol -from homeassistant.components import zeroconf +from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.core import callback @@ -56,6 +56,9 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): except ElgatoError: return self.async_abort(reason="cannot_connect") + if not onboarding.async_is_onboarded(self.hass): + return self._async_create_entry() + self._set_confirm_only() return self.async_show_form( step_id="zeroconf_confirm", diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 8b9a7bce7e1..e13119c2887 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -25,7 +26,7 @@ from homeassistant.helpers.update_coordinator import ( ) from . import HomeAssistantElgatoData -from .const import DOMAIN, LOGGER, SERVICE_IDENTIFY +from .const import DOMAIN, SERVICE_IDENTIFY from .entity import ElgatoEntity PARALLEL_UPDATES = 1 @@ -121,9 +122,12 @@ class ElgatoLight( """Turn off the light.""" try: await self.client.light(on=False) - except ElgatoError: - LOGGER.error("An error occurred while updating the Elgato Light") - await self.coordinator.async_refresh() + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while updating the Elgato Light" + ) from error + finally: + await self.coordinator.async_refresh() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -159,14 +163,18 @@ class ElgatoLight( saturation=saturation, temperature=temperature, ) - except ElgatoError: - LOGGER.error("An error occurred while updating the Elgato Light") - await self.coordinator.async_refresh() + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while updating the Elgato Light" + ) from error + finally: + await self.coordinator.async_refresh() async def async_identify(self) -> None: """Identify the light, will make it blink.""" try: await self.client.identify() - except ElgatoError: - LOGGER.exception("An error occurred while identifying the Elgato Light") - await self.coordinator.async_refresh() + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while identifying the Elgato Light" + ) from error diff --git a/homeassistant/components/elkm1/translations/bg.json b/homeassistant/components/elkm1/translations/bg.json index 5e83523d419..46a60e96408 100644 --- a/homeassistant/components/elkm1/translations/bg.json +++ b/homeassistant/components/elkm1/translations/bg.json @@ -5,6 +5,9 @@ "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "flow_title": "{mac_address} ({host})", "step": { "discovered_connection": { diff --git a/homeassistant/components/elkm1/translations/es.json b/homeassistant/components/elkm1/translations/es.json index 2bd75f83e2f..9df8e1f4ddf 100644 --- a/homeassistant/components/elkm1/translations/es.json +++ b/homeassistant/components/elkm1/translations/es.json @@ -5,6 +5,7 @@ "already_configured": "Ya est\u00e1 configurado un Elk-M1 con este prefijo", "already_in_progress": "La configuraci\u00f3n ya se encuentra en proceso", "cannot_connect": "Error al conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, "error": { diff --git a/homeassistant/components/elkm1/translations/sv.json b/homeassistant/components/elkm1/translations/sv.json index 19d9bb17e4b..8765d95baf6 100644 --- a/homeassistant/components/elkm1/translations/sv.json +++ b/homeassistant/components/elkm1/translations/sv.json @@ -4,6 +4,19 @@ "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", "invalid_auth": "Ogiltig autentisering", "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "discovered_connection": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "manual_connection": { + "data": { + "protocol": "Protokoll", + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 6872a555b8a..0c1a0148205 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -1,6 +1,7 @@ """Config flow for elmax-cloud integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -167,10 +168,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="panels", data_schema=self._panels_schema, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._reauth_username = user_input.get(CONF_ELMAX_USERNAME) - self._reauth_panelid = user_input.get(CONF_ELMAX_PANEL_ID) + self._reauth_username = entry_data.get(CONF_ELMAX_USERNAME) + self._reauth_panelid = entry_data.get(CONF_ELMAX_PANEL_ID) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm(self, user_input=None): diff --git a/homeassistant/components/elmax/translations/sv.json b/homeassistant/components/elmax/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/elmax/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emonitor/translations/bg.json b/homeassistant/components/emonitor/translations/bg.json new file mode 100644 index 00000000000..e8940bef26a --- /dev/null +++ b/homeassistant/components/emonitor/translations/bg.json @@ -0,0 +1,5 @@ +{ + "config": { + "flow_title": "{name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/emonitor/translations/fr.json b/homeassistant/components/emonitor/translations/fr.json index aaacc8bf140..ce6070be8b8 100644 --- a/homeassistant/components/emonitor/translations/fr.json +++ b/homeassistant/components/emonitor/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Voulez-vous configurer {name} ( {host} )?", + "description": "Voulez-vous configurer {name} ({host})\u00a0?", "title": "Configurer SiteSage Emonitor" }, "user": { diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index d3586ab5fcf..ec06f70a3cc 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -1,4 +1,6 @@ """Support for local control of entities by emulating a Philips Hue bridge.""" +from __future__ import annotations + import logging from aiohttp import web @@ -11,11 +13,30 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import storage +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +from .config import ( + CONF_ADVERTISE_IP, + CONF_ADVERTISE_PORT, + CONF_ENTITY_HIDDEN, + CONF_ENTITY_NAME, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_HOST_IP, + CONF_LIGHTS_ALL_DIMMABLE, + CONF_LISTEN_PORT, + CONF_OFF_MAPS_TO_ON_DOMAINS, + CONF_UPNP_BIND_MULTICAST, + DEFAULT_LIGHTS_ALL_DIMMABLE, + DEFAULT_LISTEN_PORT, + DEFAULT_TYPE, + TYPE_ALEXA, + TYPE_GOOGLE, + Config, +) +from .const import DOMAIN from .hue_api import ( HueAllGroupsStateView, HueAllLightsStateView, @@ -27,46 +48,10 @@ from .hue_api import ( HueUnauthorizedUser, HueUsernameView, ) -from .upnp import DescriptionXmlView, create_upnp_datagram_endpoint - -DOMAIN = "emulated_hue" +from .upnp import DescriptionXmlView, async_create_upnp_datagram_endpoint _LOGGER = logging.getLogger(__name__) -NUMBERS_FILE = "emulated_hue_ids.json" -DATA_KEY = "emulated_hue.ids" -DATA_VERSION = "1" -SAVE_DELAY = 60 - -CONF_ADVERTISE_IP = "advertise_ip" -CONF_ADVERTISE_PORT = "advertise_port" -CONF_ENTITY_HIDDEN = "hidden" -CONF_ENTITY_NAME = "name" -CONF_EXPOSE_BY_DEFAULT = "expose_by_default" -CONF_EXPOSED_DOMAINS = "exposed_domains" -CONF_HOST_IP = "host_ip" -CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable" -CONF_LISTEN_PORT = "listen_port" -CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains" -CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast" - -TYPE_ALEXA = "alexa" -TYPE_GOOGLE = "google_home" - -DEFAULT_LIGHTS_ALL_DIMMABLE = False -DEFAULT_LISTEN_PORT = 8300 -DEFAULT_UPNP_BIND_MULTICAST = True -DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ["script", "scene"] -DEFAULT_EXPOSE_BY_DEFAULT = True -DEFAULT_EXPOSED_DOMAINS = [ - "switch", - "light", - "group", - "input_boolean", - "media_player", - "fan", -] -DEFAULT_TYPE = TYPE_GOOGLE CONFIG_ENTITY_SCHEMA = vol.Schema( { @@ -75,6 +60,7 @@ CONFIG_ENTITY_SCHEMA = vol.Schema( } ) + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -102,7 +88,39 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -ATTR_EMULATED_HUE_NAME = "emulated_hue_name" + +async def start_emulated_hue_bridge( + hass: HomeAssistant, config: Config, app: web.Application +) -> None: + """Start the emulated hue bridge.""" + protocol = await async_create_upnp_datagram_endpoint( + config.host_ip_addr, + config.upnp_bind_multicast, + config.advertise_ip, + config.advertise_port or config.listen_port, + ) + + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) + + try: + await site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server at port %d: %s", config.listen_port, error + ) + protocol.close() + return + + async def stop_emulated_hue_bridge(event: Event) -> None: + """Stop the emulated hue bridge.""" + protocol.close() + await site.stop() + await runner.cleanup() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: @@ -120,9 +138,6 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: app._on_startup.freeze() await app.startup() - runner = None - site = None - DescriptionXmlView(config).register(app, app.router) HueUsernameView().register(app, app.router) HueConfigView(config).register(app, app.router) @@ -134,213 +149,10 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: HueGroupView(config).register(app, app.router) HueFullStateView(config).register(app, app.router) - listen = create_upnp_datagram_endpoint( - config.host_ip_addr, - config.upnp_bind_multicast, - config.advertise_ip, - config.advertise_port or config.listen_port, - ) - protocol = None + async def _start(event: Event) -> None: + """Start the bridge.""" + await start_emulated_hue_bridge(hass, config, app) - async def stop_emulated_hue_bridge(event): - """Stop the emulated hue bridge.""" - nonlocal protocol - nonlocal site - nonlocal runner - - if protocol: - protocol.close() - if site: - await site.stop() - if runner: - await runner.cleanup() - - async def start_emulated_hue_bridge(event): - """Start the emulated hue bridge.""" - nonlocal protocol - nonlocal site - nonlocal runner - - _, protocol = await listen - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) - - try: - await site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server at port %d: %s", config.listen_port, error - ) - if protocol: - protocol.close() - else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge - ) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start) return True - - -class Config: - """Hold configuration variables for the emulated hue bridge.""" - - def __init__(self, hass, conf, local_ip): - """Initialize the instance.""" - self.hass = hass - self.type = conf.get(CONF_TYPE) - self.numbers = None - self.store = None - self.cached_states = {} - self._exposed_cache = {} - - if self.type == TYPE_ALEXA: - _LOGGER.warning( - "Emulated Hue running in legacy mode because type has been " - "specified. More info at https://goo.gl/M6tgz8" - ) - - # Get the IP address that will be passed to the Echo during discovery - self.host_ip_addr = conf.get(CONF_HOST_IP) - if self.host_ip_addr is None: - self.host_ip_addr = local_ip - - # Get the port that the Hue bridge will listen on - self.listen_port = conf.get(CONF_LISTEN_PORT) - if not isinstance(self.listen_port, int): - self.listen_port = DEFAULT_LISTEN_PORT - _LOGGER.info( - "Listen port not specified, defaulting to %s", self.listen_port - ) - - # Get whether or not UPNP binds to multicast address (239.255.255.250) - # or to the unicast address (host_ip_addr) - self.upnp_bind_multicast = conf.get( - CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST - ) - - # Get domains that cause both "on" and "off" commands to map to "on" - # This is primarily useful for things like scenes or scripts, which - # don't really have a concept of being off - self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS) - if not isinstance(self.off_maps_to_on_domains, list): - self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS - - # Get whether or not entities should be exposed by default, or if only - # explicitly marked ones will be exposed - self.expose_by_default = conf.get( - CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT - ) - - # Get domains that are exposed by default when expose_by_default is - # True - self.exposed_domains = set( - conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) - ) - - # Calculated effective advertised IP and port for network isolation - self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr - - self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port - - self.entities = conf.get(CONF_ENTITIES, {}) - - self._entities_with_hidden_attr_in_config = {} - for entity_id in self.entities: - hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN) - if hidden_value is not None: - self._entities_with_hidden_attr_in_config[entity_id] = hidden_value - - # Get whether all non-dimmable lights should be reported as dimmable - # for compatibility with older installations. - self.lights_all_dimmable = conf.get(CONF_LIGHTS_ALL_DIMMABLE) - - async def async_setup(self): - """Set up and migrate to storage.""" - self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) - self.numbers = ( - await storage.async_migrator( - self.hass, self.hass.config.path(NUMBERS_FILE), self.store - ) - or {} - ) - - def entity_id_to_number(self, entity_id): - """Get a unique number for the entity id.""" - if self.type == TYPE_ALEXA: - return entity_id - - # Google Home - for number, ent_id in self.numbers.items(): - if entity_id == ent_id: - return number - - number = "1" - if self.numbers: - number = str(max(int(k) for k in self.numbers) + 1) - self.numbers[number] = entity_id - self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY) - return number - - def number_to_entity_id(self, number): - """Convert unique number to entity id.""" - if self.type == TYPE_ALEXA: - return number - - # Google Home - assert isinstance(number, str) - return self.numbers.get(number) - - def get_entity_name(self, entity): - """Get the name of an entity.""" - if ( - entity.entity_id in self.entities - and CONF_ENTITY_NAME in self.entities[entity.entity_id] - ): - return self.entities[entity.entity_id][CONF_ENTITY_NAME] - - return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name) - - def is_entity_exposed(self, entity): - """Cache determine if an entity should be exposed on the emulated bridge.""" - entity_id = entity.entity_id - if entity_id not in self._exposed_cache: - self._exposed_cache[entity_id] = self._is_entity_exposed(entity) - return self._exposed_cache[entity_id] - - def filter_exposed_entities(self, states): - """Filter a list of all states down to exposed entities.""" - exposed = [] - for entity in states: - entity_id = entity.entity_id - if entity_id not in self._exposed_cache: - self._exposed_cache[entity_id] = self._is_entity_exposed(entity) - if self._exposed_cache[entity_id]: - exposed.append(entity) - return exposed - - def _is_entity_exposed(self, entity): - """Determine if an entity should be exposed on the emulated bridge. - - Async friendly. - """ - if entity.attributes.get("view") is not None: - # Ignore entities that are views - return False - - if entity.entity_id in self._entities_with_hidden_attr_in_config: - return not self._entities_with_hidden_attr_in_config[entity.entity_id] - - if not self.expose_by_default: - return False - # Expose an entity if the entity's domain is exposed by default and - # the configuration doesn't explicitly exclude it from being - # exposed, or if the entity is explicitly exposed - if entity.domain in self.exposed_domains: - return True - - return False diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py new file mode 100644 index 00000000000..1de6ec98520 --- /dev/null +++ b/homeassistant/components/emulated_hue/config.py @@ -0,0 +1,257 @@ +"""Support for local control of entities by emulating a Philips Hue bridge.""" +from __future__ import annotations + +from functools import cache +import logging + +from homeassistant.components import ( + climate, + cover, + fan, + humidifier, + light, + media_player, + scene, + script, +) +from homeassistant.const import CONF_ENTITIES, CONF_TYPE +from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id +from homeassistant.helpers import storage +from homeassistant.helpers.event import ( + async_track_state_added_domain, + async_track_state_removed_domain, +) +from homeassistant.helpers.typing import ConfigType + +SUPPORTED_DOMAINS = { + climate.DOMAIN, + cover.DOMAIN, + fan.DOMAIN, + humidifier.DOMAIN, + light.DOMAIN, + media_player.DOMAIN, + scene.DOMAIN, + script.DOMAIN, +} + + +TYPE_ALEXA = "alexa" +TYPE_GOOGLE = "google_home" + + +NUMBERS_FILE = "emulated_hue_ids.json" +DATA_KEY = "emulated_hue.ids" +DATA_VERSION = "1" +SAVE_DELAY = 60 + +CONF_ADVERTISE_IP = "advertise_ip" +CONF_ADVERTISE_PORT = "advertise_port" +CONF_ENTITY_HIDDEN = "hidden" +CONF_ENTITY_NAME = "name" +CONF_EXPOSE_BY_DEFAULT = "expose_by_default" +CONF_EXPOSED_DOMAINS = "exposed_domains" +CONF_HOST_IP = "host_ip" +CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable" +CONF_LISTEN_PORT = "listen_port" +CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains" +CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast" + + +DEFAULT_LIGHTS_ALL_DIMMABLE = False +DEFAULT_LISTEN_PORT = 8300 +DEFAULT_UPNP_BIND_MULTICAST = True +DEFAULT_OFF_MAPS_TO_ON_DOMAINS = {"script", "scene"} +DEFAULT_EXPOSE_BY_DEFAULT = True +DEFAULT_EXPOSED_DOMAINS = [ + "switch", + "light", + "group", + "input_boolean", + "media_player", + "fan", +] +DEFAULT_TYPE = TYPE_GOOGLE + +ATTR_EMULATED_HUE_NAME = "emulated_hue_name" + + +_LOGGER = logging.getLogger(__name__) + + +class Config: + """Hold configuration variables for the emulated hue bridge.""" + + def __init__(self, hass: HomeAssistant, conf: ConfigType, local_ip: str) -> None: + """Initialize the instance.""" + self.hass = hass + self.type = conf.get(CONF_TYPE) + self.numbers: dict[str, str] = {} + self.store: storage.Store | None = None + self.cached_states: dict[str, list] = {} + self._exposed_cache: dict[str, bool] = {} + + if self.type == TYPE_ALEXA: + _LOGGER.warning( + "Emulated Hue running in legacy mode because type has been " + "specified. More info at https://goo.gl/M6tgz8" + ) + + # Get the IP address that will be passed to the Echo during discovery + self.host_ip_addr: str = conf.get(CONF_HOST_IP) or local_ip + + # Get the port that the Hue bridge will listen on + self.listen_port: int = conf.get(CONF_LISTEN_PORT) or DEFAULT_LISTEN_PORT + + # Get whether or not UPNP binds to multicast address (239.255.255.250) + # or to the unicast address (host_ip_addr) + self.upnp_bind_multicast: bool = conf.get( + CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST + ) + + # Get domains that cause both "on" and "off" commands to map to "on" + # This is primarily useful for things like scenes or scripts, which + # don't really have a concept of being off + off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS) + if isinstance(off_maps_to_on_domains, list): + self.off_maps_to_on_domains = set(off_maps_to_on_domains) + else: + self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS + + # Get whether or not entities should be exposed by default, or if only + # explicitly marked ones will be exposed + self.expose_by_default: bool = conf.get( + CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT + ) + + # Get domains that are exposed by default when expose_by_default is + # True + self.exposed_domains = set( + conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) + ) + + # Calculated effective advertised IP and port for network isolation + self.advertise_ip: str = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr + + self.advertise_port: int = conf.get(CONF_ADVERTISE_PORT) or self.listen_port + + self.entities: dict[str, dict[str, str]] = conf.get(CONF_ENTITIES, {}) + + self._entities_with_hidden_attr_in_config = {} + for entity_id in self.entities: + hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN) + if hidden_value is not None: + self._entities_with_hidden_attr_in_config[entity_id] = hidden_value + + # Get whether all non-dimmable lights should be reported as dimmable + # for compatibility with older installations. + self.lights_all_dimmable: bool = conf.get(CONF_LIGHTS_ALL_DIMMABLE) or False + + if self.expose_by_default: + self.track_domains = set(self.exposed_domains) or SUPPORTED_DOMAINS + else: + self.track_domains = { + split_entity_id(entity_id)[0] for entity_id in self.entities + } + + async def async_setup(self) -> None: + """Set up tracking and migrate to storage.""" + hass = self.hass + self.store = storage.Store(hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type] + numbers_path = hass.config.path(NUMBERS_FILE) + self.numbers = ( + await storage.async_migrator(hass, numbers_path, self.store) or {} + ) + async_track_state_added_domain( + hass, self.track_domains, self._clear_exposed_cache + ) + async_track_state_removed_domain( + hass, self.track_domains, self._clear_exposed_cache + ) + + @cache # pylint: disable=method-cache-max-size-none + def entity_id_to_number(self, entity_id: str) -> str: + """Get a unique number for the entity id.""" + if self.type == TYPE_ALEXA: + return entity_id + + # Google Home + for number, ent_id in self.numbers.items(): + if entity_id == ent_id: + return number + + number = "1" + if self.numbers: + number = str(max(int(k) for k in self.numbers) + 1) + self.numbers[number] = entity_id + assert self.store is not None + self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY) + return number + + def number_to_entity_id(self, number: str) -> str | None: + """Convert unique number to entity id.""" + if self.type == TYPE_ALEXA: + return number + + # Google Home + return self.numbers.get(number) + + def get_entity_name(self, state: State) -> str: + """Get the name of an entity.""" + if ( + state.entity_id in self.entities + and CONF_ENTITY_NAME in self.entities[state.entity_id] + ): + return self.entities[state.entity_id][CONF_ENTITY_NAME] + + return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) + + @cache # pylint: disable=method-cache-max-size-none + def get_exposed_states(self) -> list[State]: + """Return a list of exposed states.""" + state_machine = self.hass.states + if self.expose_by_default: + return [ + state + for state in state_machine.async_all() + if self.is_state_exposed(state) + ] + states: list[State] = [] + for entity_id in self.entities: + if (state := state_machine.get(entity_id)) and self.is_state_exposed(state): + states.append(state) + return states + + @callback + def _clear_exposed_cache(self, event: Event) -> None: + """Clear the cache of exposed states.""" + self.get_exposed_states.cache_clear() # pylint: disable=no-member + + def is_state_exposed(self, state: State) -> bool: + """Cache determine if an entity should be exposed on the emulated bridge.""" + if (exposed := self._exposed_cache.get(state.entity_id)) is not None: + return exposed + exposed = self._is_state_exposed(state) + self._exposed_cache[state.entity_id] = exposed + return exposed + + def _is_state_exposed(self, state: State) -> bool: + """Determine if an entity state should be exposed on the emulated bridge. + + Async friendly. + """ + if state.attributes.get("view") is not None: + # Ignore entities that are views + return False + + if state.entity_id in self._entities_with_hidden_attr_in_config: + return not self._entities_with_hidden_attr_in_config[state.entity_id] + + if not self.expose_by_default: + return False + # Expose an entity if the entity's domain is exposed by default and + # the configuration doesn't explicitly exclude it from being + # exposed, or if the entity is explicitly exposed + if state.domain in self.exposed_domains: + return True + + return False diff --git a/homeassistant/components/emulated_hue/const.py b/homeassistant/components/emulated_hue/const.py index bfd58c5a0e1..2bcd8cbac19 100644 --- a/homeassistant/components/emulated_hue/const.py +++ b/homeassistant/components/emulated_hue/const.py @@ -2,3 +2,5 @@ HUE_SERIAL_NUMBER = "001788FFFE23BFC2" HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc" + +DOMAIN = "emulated_hue" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index b4f926afd31..c5ff9654f90 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -1,10 +1,16 @@ """Support for a Hue API to control Home Assistant.""" +from __future__ import annotations + import asyncio +from functools import lru_cache import hashlib from http import HTTPStatus from ipaddress import ip_address import logging import time +from typing import Any + +from aiohttp import web from homeassistant import core from homeassistant.components import ( @@ -58,9 +64,12 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.core import State from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.network import is_local +from .config import Config + _LOGGER = logging.getLogger(__name__) # How long to wait for a state change to happen @@ -111,7 +120,7 @@ class HueUnauthorizedUser(HomeAssistantView): extra_urls = ["/api/"] requires_auth = False - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Handle a GET request.""" return self.json(UNAUTHORIZED_USER) @@ -124,8 +133,9 @@ class HueUsernameView(HomeAssistantView): extra_urls = ["/api/"] requires_auth = False - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle a POST request.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -147,13 +157,14 @@ class HueAllGroupsStateView(HomeAssistantView): name = "emulated_hue:all_groups:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username): + def get(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Brilliant Lightpad work.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -167,13 +178,14 @@ class HueGroupView(HomeAssistantView): name = "emulated_hue:groups:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def put(self, request, username): + def put(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Logitech Pop working.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -197,13 +209,14 @@ class HueAllLightsStateView(HomeAssistantView): name = "emulated_hue:lights:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username): + def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -217,13 +230,14 @@ class HueFullStateView(HomeAssistantView): name = "emulated_hue:username:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username): + def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) if username != HUE_API_USERNAME: @@ -245,13 +259,14 @@ class HueConfigView(HomeAssistantView): name = "emulated_hue:username:config" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username=""): + def get(self, request: web.Request, username: str = "") -> web.Response: """Process a request to get the configuration.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -267,17 +282,18 @@ class HueOneLightStateView(HomeAssistantView): name = "emulated_hue:light:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username, entity_id): + def get(self, request: web.Request, username: str, entity_id: str) -> web.Response: """Process a request to get the state of an individual light.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) - hass = request.app["hass"] + hass: core.HomeAssistant = request.app["hass"] hass_entity_id = self.config.number_to_entity_id(entity_id) if hass_entity_id is None: @@ -287,15 +303,15 @@ class HueOneLightStateView(HomeAssistantView): ) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - if (entity := hass.states.get(hass_entity_id)) is None: + if (state := hass.states.get(hass_entity_id)) is None: _LOGGER.error("Entity not found: %s", hass_entity_id) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - if not self.config.is_entity_exposed(entity): + if not self.config.is_state_exposed(state): _LOGGER.error("Entity not exposed: %s", entity_id) return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) - json_response = entity_to_json(self.config, entity) + json_response = state_to_json(self.config, state) return self.json(json_response) @@ -307,17 +323,20 @@ class HueOneLightChangeView(HomeAssistantView): name = "emulated_hue:light:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config - async def put(self, request, username, entity_number): # noqa: C901 + async def put( # noqa: C901 + self, request: web.Request, username: str, entity_number: str + ) -> web.Response: """Process a request to set the state of an individual light.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) config = self.config - hass = request.app["hass"] + hass: core.HomeAssistant = request.app["hass"] entity_id = config.number_to_entity_id(entity_number) if entity_id is None: @@ -328,7 +347,7 @@ class HueOneLightChangeView(HomeAssistantView): _LOGGER.error("Entity not found: %s", entity_id) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - if not config.is_entity_exposed(entity): + if not config.is_state_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) @@ -344,7 +363,7 @@ class HueOneLightChangeView(HomeAssistantView): color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) # Parse the request - parsed = { + parsed: dict[str, Any] = { STATE_ON: False, STATE_BRIGHTNESS: None, STATE_HUE: None, @@ -416,10 +435,10 @@ class HueOneLightChangeView(HomeAssistantView): turn_on_needed = False # Convert the resulting "on" status into the service we need to call - service = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF + service: str | None = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF # Construct what we need to send to the service - data = {ATTR_ENTITY_ID: entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity_id} # If the requested entity is a light, set the brightness, hue, # saturation and color temp @@ -596,7 +615,7 @@ class HueOneLightChangeView(HomeAssistantView): return self.json(json_response) -def get_entity_state(config, entity): +def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: """Retrieve and convert state and brightness values for an entity.""" cached_state_entry = config.cached_states.get(entity.entity_id, None) cached_state = None @@ -617,7 +636,7 @@ def get_entity_state(config, entity): # Remove the now stale cached entry. config.cached_states.pop(entity.entity_id) - data = { + data: dict[str, Any] = { STATE_ON: False, STATE_BRIGHTNESS: None, STATE_HUE: None, @@ -700,22 +719,32 @@ def get_entity_state(config, entity): return data -def entity_to_json(config, entity): +@lru_cache(maxsize=1024) +def _entity_unique_id(entity_id: str) -> str: + """Return the emulated_hue unique id for the entity_id.""" + unique_id = hashlib.md5(entity_id.encode()).hexdigest() + return ( + f"00:{unique_id[0:2]}:{unique_id[2:4]}:" + f"{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:" + f"{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}" + ) + + +def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) - unique_id = hashlib.md5(entity.entity_id.encode()).hexdigest() - unique_id = f"00:{unique_id[0:2]}:{unique_id[2:4]}:{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}" + entity_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) + unique_id = _entity_unique_id(state.entity_id) + state_dict = get_entity_state_dict(config, state) - state = get_entity_state(config, entity) - - retval = { - "state": { - HUE_API_STATE_ON: state[STATE_ON], - "reachable": entity.state != STATE_UNAVAILABLE, - "mode": "homeautomation", - }, - "name": config.get_entity_name(entity), + json_state: dict[str, str | bool | int] = { + HUE_API_STATE_ON: state_dict[STATE_ON], + "reachable": state.state != STATE_UNAVAILABLE, + "mode": "homeautomation", + } + retval: dict[str, str | dict[str, str | bool | int]] = { + "state": json_state, + "name": config.get_entity_name(state), "uniqueid": unique_id, "manufacturername": "Home Assistant", "swversion": "123", @@ -726,30 +755,30 @@ def entity_to_json(config, entity): # Same as Color light, but which supports additional setting of color temperature retval["type"] = "Extended color light" retval["modelid"] = "HASS231" - retval["state"].update( + json_state.update( { - HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], - HUE_API_STATE_HUE: state[STATE_HUE], - HUE_API_STATE_SAT: state[STATE_SATURATION], - HUE_API_STATE_CT: state[STATE_COLOR_TEMP], + HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], + HUE_API_STATE_HUE: state_dict[STATE_HUE], + HUE_API_STATE_SAT: state_dict[STATE_SATURATION], + HUE_API_STATE_CT: state_dict[STATE_COLOR_TEMP], HUE_API_STATE_EFFECT: "none", } ) - if state[STATE_HUE] > 0 or state[STATE_SATURATION] > 0: - retval["state"][HUE_API_STATE_COLORMODE] = "hs" + if state_dict[STATE_HUE] > 0 or state_dict[STATE_SATURATION] > 0: + json_state[HUE_API_STATE_COLORMODE] = "hs" else: - retval["state"][HUE_API_STATE_COLORMODE] = "ct" + json_state[HUE_API_STATE_COLORMODE] = "ct" elif light.color_supported(color_modes): # Color light (Zigbee Device ID: 0x0200) # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) retval["type"] = "Color light" retval["modelid"] = "HASS213" - retval["state"].update( + json_state.update( { - HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], HUE_API_STATE_COLORMODE: "hs", - HUE_API_STATE_HUE: state[STATE_HUE], - HUE_API_STATE_SAT: state[STATE_SATURATION], + HUE_API_STATE_HUE: state_dict[STATE_HUE], + HUE_API_STATE_SAT: state_dict[STATE_SATURATION], HUE_API_STATE_EFFECT: "none", } ) @@ -758,11 +787,11 @@ def entity_to_json(config, entity): # Supports groups, scenes, on/off, dimming, and setting of a color temperature retval["type"] = "Color temperature light" retval["modelid"] = "HASS312" - retval["state"].update( + json_state.update( { HUE_API_STATE_COLORMODE: "ct", - HUE_API_STATE_CT: state[STATE_COLOR_TEMP], - HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_CT: state_dict[STATE_COLOR_TEMP], + HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], } ) elif entity_features & ( @@ -775,7 +804,7 @@ def entity_to_json(config, entity): # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" retval["modelid"] = "HASS123" - retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) + json_state.update({HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS]}) elif not config.lights_all_dimmable: # On/Off light (ZigBee Device ID: 0x0000) # Supports groups, scenes and on/off control @@ -788,18 +817,20 @@ def entity_to_json(config, entity): # Reports fixed brightness for compatibility with Alexa. retval["type"] = "Dimmable light" retval["modelid"] = "HASS123" - retval["state"].update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) + json_state.update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) return retval -def create_hue_success_response(entity_number, attr, value): +def create_hue_success_response( + entity_number: str, attr: str, value: str +) -> dict[str, Any]: """Create a success response for an attribute set on a light.""" success_key = f"/lights/{entity_number}/state/{attr}" return {"success": {success_key: value}} -def create_config_model(config, request): +def create_config_model(config: Config, request: web.Request) -> dict[str, Any]: """Create a config resource.""" return { "mac": "00:00:00:00:00:00", @@ -811,34 +842,33 @@ def create_config_model(config, request): } -def create_list_of_entities(config, request): +def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]: """Create a list of all entities.""" - hass = request.app["hass"] - json_response = {} - - for entity in config.filter_exposed_entities(hass.states.async_all()): - number = config.entity_id_to_number(entity.entity_id) - json_response[number] = entity_to_json(config, entity) - + json_response: dict[str, Any] = { + config.entity_id_to_number(state.entity_id): state_to_json(config, state) + for state in config.get_exposed_states() + } return json_response -def hue_brightness_to_hass(value): +def hue_brightness_to_hass(value: int) -> int: """Convert hue brightness 1..254 to hass format 0..255.""" return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255)) -def hass_to_hue_brightness(value): +def hass_to_hue_brightness(value: int) -> int: """Convert hass brightness 0..255 to hue 1..254 scale.""" return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) -async def wait_for_state_change_or_timeout(hass, entity_id, timeout): +async def wait_for_state_change_or_timeout( + hass: core.HomeAssistant, entity_id: str, timeout: float +) -> None: """Wait for an entity to change state.""" ev = asyncio.Event() @core.callback - def _async_event_changed(_): + def _async_event_changed(event: core.Event) -> None: ev.set() unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index e5a9072e51d..be5271b78e3 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -5,7 +5,7 @@ "requirements": ["aiohttp_cors==0.7.0"], "dependencies": ["network"], "after_dependencies": ["http"], - "codeowners": [], + "codeowners": ["@bdraco"], "quality_scale": "internal", "iot_class": "local_push" } diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 797b22c22f7..ca8c0a45281 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,13 +1,17 @@ """Support UPNP discovery method that mimics Hue hubs.""" +from __future__ import annotations + import asyncio import logging import socket +from typing import cast from aiohttp import web from homeassistant import core from homeassistant.components.http import HomeAssistantView +from .config import Config from .const import HUE_SERIAL_NUMBER, HUE_UUID _LOGGER = logging.getLogger(__name__) @@ -23,12 +27,12 @@ class DescriptionXmlView(HomeAssistantView): name = "description:xml" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Handle a GET request.""" resp_text = f""" @@ -55,13 +59,91 @@ class DescriptionXmlView(HomeAssistantView): return web.Response(text=resp_text, content_type="text/xml") -@core.callback -def create_upnp_datagram_endpoint( - host_ip_addr, - upnp_bind_multicast, - advertise_ip, - advertise_port, -): +class UPNPResponderProtocol(asyncio.Protocol): + """Handle responding to UPNP/SSDP discovery requests.""" + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + ssdp_socket: socket.socket, + advertise_ip: str, + advertise_port: int, + ) -> None: + """Initialize the class.""" + self.transport: asyncio.DatagramTransport | None = None + self._loop = loop + self._sock = ssdp_socket + self.advertise_ip = advertise_ip + self.advertise_port = advertise_port + self._upnp_root_response = self._prepare_response( + "upnp:rootdevice", f"uuid:{HUE_UUID}::upnp:rootdevice" + ) + self._upnp_device_response = self._prepare_response( + "urn:schemas-upnp-org:device:basic:1", f"uuid:{HUE_UUID}" + ) + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Set the transport.""" + self.transport = cast(asyncio.DatagramTransport, transport) + + def connection_lost(self, exc: Exception | None) -> None: + """Handle connection lost.""" + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Respond to msearch packets.""" + decoded_data = data.decode("utf-8", errors="ignore") + + if "M-SEARCH" not in decoded_data: + return + + _LOGGER.debug("UPNP Responder M-SEARCH method received: %s", data) + # SSDP M-SEARCH method received, respond to it with our info + response = self._handle_request(decoded_data) + _LOGGER.debug("UPNP Responder responding with: %s", response) + assert self.transport is not None + self.transport.sendto(response, addr) + + def error_received(self, exc: Exception) -> None: + """Log UPNP errors.""" + _LOGGER.error("UPNP Error received: %s", exc) + + def close(self) -> None: + """Stop the server.""" + _LOGGER.info("UPNP responder shutting down") + if self.transport: + self.transport.close() + self._loop.remove_writer(self._sock.fileno()) + self._loop.remove_reader(self._sock.fileno()) + self._sock.close() + + def _handle_request(self, decoded_data: str) -> bytes: + if "upnp:rootdevice" in decoded_data: + return self._upnp_root_response + + return self._upnp_device_response + + def _prepare_response(self, search_target: str, unique_service_name: str) -> bytes: + # Note that the double newline at the end of + # this string is required per the SSDP spec + response = f"""HTTP/1.1 200 OK +CACHE-CONTROL: max-age=60 +EXT: +LOCATION: http://{self.advertise_ip}:{self.advertise_port}/description.xml +SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 +hue-bridgeid: {HUE_SERIAL_NUMBER} +ST: {search_target} +USN: {unique_service_name} + +""" + return response.replace("\n", "\r\n").encode("utf-8") + + +async def async_create_upnp_datagram_endpoint( + host_ip_addr: str, + upnp_bind_multicast: bool, + advertise_ip: str, + advertise_port: int, +) -> UPNPResponderProtocol: """Create the UPNP socket and protocol.""" # Listen for UDP port 1900 packets sent to SSDP multicast address ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -84,79 +166,8 @@ def create_upnp_datagram_endpoint( loop = asyncio.get_event_loop() - return loop.create_datagram_endpoint( + transport_protocol = await loop.create_datagram_endpoint( lambda: UPNPResponderProtocol(loop, ssdp_socket, advertise_ip, advertise_port), sock=ssdp_socket, ) - - -class UPNPResponderProtocol: - """Handle responding to UPNP/SSDP discovery requests.""" - - def __init__(self, loop, ssdp_socket, advertise_ip, advertise_port): - """Initialize the class.""" - self.transport = None - self._loop = loop - self._sock = ssdp_socket - self.advertise_ip = advertise_ip - self.advertise_port = advertise_port - self._upnp_root_response = self._prepare_response( - "upnp:rootdevice", f"uuid:{HUE_UUID}::upnp:rootdevice" - ) - self._upnp_device_response = self._prepare_response( - "urn:schemas-upnp-org:device:basic:1", f"uuid:{HUE_UUID}" - ) - - def connection_made(self, transport): - """Set the transport.""" - self.transport = transport - - def connection_lost(self, exc): - """Handle connection lost.""" - - def datagram_received(self, data, addr): - """Respond to msearch packets.""" - decoded_data = data.decode("utf-8", errors="ignore") - - if "M-SEARCH" not in decoded_data: - return - - _LOGGER.debug("UPNP Responder M-SEARCH method received: %s", data) - # SSDP M-SEARCH method received, respond to it with our info - response = self._handle_request(decoded_data) - _LOGGER.debug("UPNP Responder responding with: %s", response) - self.transport.sendto(response, addr) - - def error_received(self, exc): - """Log UPNP errors.""" - _LOGGER.error("UPNP Error received: %s", exc) - - def close(self): - """Stop the server.""" - _LOGGER.info("UPNP responder shutting down") - if self.transport: - self.transport.close() - self._loop.remove_writer(self._sock.fileno()) - self._loop.remove_reader(self._sock.fileno()) - self._sock.close() - - def _handle_request(self, decoded_data): - if "upnp:rootdevice" in decoded_data: - return self._upnp_root_response - - return self._upnp_device_response - - def _prepare_response(self, search_target, unique_service_name): - # Note that the double newline at the end of - # this string is required per the SSDP spec - response = f"""HTTP/1.1 200 OK -CACHE-CONTROL: max-age=60 -EXT: -LOCATION: http://{self.advertise_ip}:{self.advertise_port}/description.xml -SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 -hue-bridgeid: {HUE_SERIAL_NUMBER} -ST: {search_target} -USN: {unique_service_name} - -""" - return response.replace("\n", "\r\n").encode("utf-8") + return transport_protocol[1] diff --git a/homeassistant/components/energy/translations/sv.json b/homeassistant/components/energy/translations/sv.json new file mode 100644 index 00000000000..168ae4ae877 --- /dev/null +++ b/homeassistant/components/energy/translations/sv.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index e48a576f44e..3baae348770 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -10,6 +10,7 @@ from homeassistant.components import recorder, sensor from homeassistant.const import ( ATTR_DEVICE_CLASS, ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, ENERGY_WATT_HOUR, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -23,7 +24,11 @@ from .const import DOMAIN ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_UNITS = { - sensor.SensorDeviceClass.ENERGY: (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) + sensor.SensorDeviceClass.ENERGY: ( + ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, + ) } ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 696baa31775..0c6c893df64 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -96,3 +97,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove an enphase_envoy config entry from a device.""" + dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN} + data: dict = hass.data[DOMAIN][config_entry.entry_id] + coordinator: DataUpdateCoordinator = data[COORDINATOR] + envoy_data: dict = coordinator.data + envoy_serial_num = config_entry.unique_id + if envoy_serial_num in dev_ids: + return False + for inverter in envoy_data.get("inverters_production", []): + if str(inverter) in dev_ids: + return False + return True diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 88310579e72..3707733b1af 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Enphase Envoy integration.""" from __future__ import annotations +from collections.abc import Mapping import contextlib import logging from typing import Any @@ -110,7 +111,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_reauth(self, user_input): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/enphase_envoy/translations/bg.json b/homeassistant/components/enphase_envoy/translations/bg.json new file mode 100644 index 00000000000..ffb593eb287 --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/bg.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index b79323b0462..40706ffb6c1 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -17,14 +17,19 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_KILOMETERS, + PRESSURE_KPA, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -63,6 +68,11 @@ async def async_setup_entry( class ECWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = PRESSURE_KPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, coordinator, hourly): """Initialize Environment Canada weather.""" super().__init__(coordinator) @@ -78,7 +88,7 @@ class ECWeather(CoordinatorEntity, WeatherEntity): self._hourly = hourly @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" if ( temperature := self.ec_data.conditions.get("temperature", {}).get("value") @@ -92,11 +102,6 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return float(temperature) return None - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def humidity(self): """Return the humidity.""" @@ -105,7 +110,7 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return None @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" if self.ec_data.conditions.get("wind_speed", {}).get("value"): return float(self.ec_data.conditions["wind_speed"]["value"]) @@ -119,14 +124,14 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return None @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" if self.ec_data.conditions.get("pressure", {}).get("value"): - return 10 * float(self.ec_data.conditions["pressure"]["value"]) + return float(self.ec_data.conditions["pressure"]["value"]) return None @property - def visibility(self): + def native_visibility(self): """Return the visibility.""" if self.ec_data.conditions.get("visibility", {}).get("value"): return float(self.ec_data.conditions["visibility"]["value"]) @@ -175,16 +180,16 @@ def get_forecast(ec_data, hourly): if half_days[0]["temperature_class"] == "high": today.update( { - ATTR_FORECAST_TEMP: int(half_days[0]["temperature"]), - ATTR_FORECAST_TEMP_LOW: int(half_days[1]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: int(half_days[0]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[1]["temperature"]), } ) half_days = half_days[2:] else: today.update( { - ATTR_FORECAST_TEMP: None, - ATTR_FORECAST_TEMP_LOW: int(half_days[0]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: None, + ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[0]["temperature"]), } ) half_days = half_days[1:] @@ -197,8 +202,8 @@ def get_forecast(ec_data, hourly): ATTR_FORECAST_TIME: ( dt.now() + datetime.timedelta(days=day) ).isoformat(), - ATTR_FORECAST_TEMP: int(half_days[high]["temperature"]), - ATTR_FORECAST_TEMP_LOW: int(half_days[low]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: int(half_days[high]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[low]["temperature"]), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(half_days[high]["icon_code"]) ), @@ -213,7 +218,7 @@ def get_forecast(ec_data, hourly): forecast_array.append( { ATTR_FORECAST_TIME: hour["period"].isoformat(), - ATTR_FORECAST_TEMP: int(hour["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: int(hour["temperature"]), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(hour["icon_code"]) ), diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 3dfa1e0762d..ad65bf70275 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -121,7 +121,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): _LOGGER.debug("Setting up alarm: %s", alarm_name) super().__init__(alarm_name, info, controller) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -141,14 +141,14 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): self.async_write_ha_state() @property - def code_format(self): + def code_format(self) -> CodeFormat | None: """Regex for code format or None if no code is required.""" if self._code: return None return CodeFormat.NUMBER @property - def state(self): + def state(self) -> str: """Return the state of the device.""" state = STATE_UNKNOWN @@ -168,7 +168,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): state = STATE_ALARM_DISARMED return state - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code: self.hass.data[DATA_EVL].disarm_partition(str(code), self._partition_number) @@ -177,7 +177,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): str(self._code), self._partition_number ) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if code: self.hass.data[DATA_EVL].arm_stay_partition( @@ -188,7 +188,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): str(self._code), self._partition_number ) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if code: self.hass.data[DATA_EVL].arm_away_partition( @@ -199,11 +199,11 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): str(self._code), self._partition_number ) - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Alarm trigger command. Will be used to trigger a panic alarm.""" self.hass.data[DATA_EVL].panic_alarm(self._panic_type) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" self.hass.data[DATA_EVL].arm_night_partition( str(code) if code else str(self._code), self._partition_number diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py index 3fd7daeea86..d82f90aa4f4 100644 --- a/homeassistant/components/envisalink/binary_sensor.py +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -93,6 +93,12 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorEntity): last_trip_time = None attr[ATTR_LAST_TRIP_TIME] = last_trip_time + + # Expose the zone number as an attribute to allow + # for easier entity to zone mapping (e.g. to bypass + # the zone). + attr["zone"] = self._zone_number + return attr @property diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index 2154cd68772..44a40991a37 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -2,7 +2,7 @@ "domain": "envisalink", "name": "Envisalink", "documentation": "https://www.home-assistant.io/integrations/envisalink", - "requirements": ["pyenvisalink==4.4"], + "requirements": ["pyenvisalink==4.5"], "codeowners": ["@ufodone"], "iot_class": "local_push", "loggers": ["pyenvisalink"] diff --git a/homeassistant/components/epson/translations/sv.json b/homeassistant/components/epson/translations/sv.json new file mode 100644 index 00000000000..e7ec27624a5 --- /dev/null +++ b/homeassistant/components/epson/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 8aa2dba4d07..ddedaf11ceb 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -150,11 +150,6 @@ async def async_setup_entry( # noqa: C901 hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) ) - @callback - def async_on_state(state: EntityState) -> None: - """Send dispatcher updates when a new state is received.""" - entry_data.async_update_state(hass, state) - @callback def async_on_service_call(service: HomeassistantServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" @@ -288,7 +283,7 @@ async def async_setup_entry( # noqa: C901 entity_infos, services = await cli.list_entities_services() await entry_data.async_update_static_infos(hass, entry, entity_infos) await _setup_services(hass, entry_data, services) - await cli.subscribe_states(async_on_state) + await cli.subscribe_states(entry_data.async_update_state) await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_home_assistant_states(async_on_state_subscription) @@ -563,7 +558,7 @@ async def platform_async_setup_entry( entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) entry_data.info[component_key] = {} entry_data.old_info[component_key] = {} - entry_data.state[component_key] = {} + entry_data.state.setdefault(state_type, {}) @callback def async_list_entities(infos: list[EntityInfo]) -> None: @@ -583,7 +578,7 @@ async def platform_async_setup_entry( old_infos.pop(info.key) else: # Create new entity - entity = entity_type(entry_data, component_key, info.key) + entity = entity_type(entry_data, component_key, info.key, state_type) add_entities.append(entity) new_infos[info.key] = info @@ -604,22 +599,6 @@ async def platform_async_setup_entry( async_dispatcher_connect(hass, signal, async_list_entities) ) - @callback - def async_entity_state(state: EntityState) -> None: - """Notify the appropriate entity of an updated state.""" - if not isinstance(state, state_type): - return - # cast back to upper type, otherwise mypy gets confused - state = cast(EntityState, state) - - entry_data.state[component_key][state.key] = state - entry_data.async_update_entity(hass, component_key, state.key) - - signal = f"esphome_{entry.entry_id}_on_state" - entry_data.cleanup_callbacks.append( - async_dispatcher_connect(hass, signal, async_entity_state) - ) - _PropT = TypeVar("_PropT", bound=Callable[..., Any]) @@ -698,12 +677,17 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Define a base esphome entity.""" def __init__( - self, entry_data: RuntimeEntryData, component_key: str, key: int + self, + entry_data: RuntimeEntryData, + component_key: str, + key: int, + state_type: type[_StateT], ) -> None: """Initialize.""" self._entry_data = entry_data self._component_key = component_key self._key = key + self._state_type = state_type async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -727,13 +711,8 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) self.async_on_remove( - async_dispatcher_connect( - self.hass, - ( - f"esphome_{self._entry_id}" - f"_update_{self._component_key}_{self._key}" - ), - self._on_state_update, + self._entry_data.async_subscribe_state_update( + self._state_type, self._key, self._on_state_update ) ) @@ -781,11 +760,11 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): @property def _state(self) -> _StateT: - return cast(_StateT, self._entry_data.state[self._component_key][self._key]) + return cast(_StateT, self._entry_data.state[self._state_type][self._key]) @property def _has_state(self) -> bool: - return self._key in self._entry_data.state[self._component_key] + return self._key in self._entry_data.state[self._state_type] @property def available(self) -> bool: diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 5b6f2c153c8..3f610c8bbfa 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -2,7 +2,6 @@ from __future__ import annotations from contextlib import suppress -from typing import Any from aioesphomeapi import ButtonInfo, EntityState @@ -46,6 +45,6 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): # never gets a state update. self._on_state_update() - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" await self._client.button_command(self._static_info.key) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index b73743ee950..ecfa381bc69 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import OrderedDict +from collections.abc import Mapping from typing import Any from aioesphomeapi import ( @@ -15,7 +16,7 @@ from aioesphomeapi import ( ) import voluptuous as vol -from homeassistant.components import zeroconf +from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback @@ -63,7 +64,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" return await self._async_step_user_base(user_input=user_input) - async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a flow initialized by a reauth event.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry is not None @@ -188,6 +189,49 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle DHCP discovery.""" + node_name = discovery_info.hostname + + await self.async_set_unique_id(node_name) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + for entry in self._async_current_entries(): + found = False + + if CONF_HOST in entry.data and entry.data[CONF_HOST] in ( + discovery_info.ip, + f"{node_name}.local", + ): + # Is this address or IP address already configured? + found = True + elif DomainData.get(self.hass).is_entry_loaded(entry): + # Does a config entry with this name already exist? + data = DomainData.get(self.hass).get_entry_data(entry) + + # Node names are unique in the network + if data.device_info is not None: + found = data.device_info.name == node_name + + if found: + # Backwards compat, we update old entries + if not entry.unique_id: + self.hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_HOST: discovery_info.ip, + }, + unique_id=node_name, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + + break + + return self.async_abort(reason="already_configured") + @callback def _async_get_entry(self) -> FlowResult: config_data = { diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 4296c899253..97ae22dcccc 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -111,7 +111,7 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """Stop the cover.""" await self._client.cover_command(key=self._static_info.key, stop=True) - async def async_set_cover_position(self, **kwargs: int) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self._client.cover_command( key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100 @@ -125,8 +125,9 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """Close the cover tilt.""" await self._client.cover_command(key=self._static_info.key, tilt=0.0) - async def async_set_cover_tilt_position(self, **kwargs: int) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" + tilt_position: int = kwargs[ATTR_TILT_POSITION] await self._client.cover_command( - key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100 + key=self._static_info.key, tilt=tilt_position / 100 ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 4c5a94afe0f..41a0e89245e 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass, field +import logging from typing import Any, cast from aioesphomeapi import ( @@ -31,28 +32,30 @@ from aioesphomeapi import ( from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store SAVE_DELAY = 120 +_LOGGER = logging.getLogger(__name__) # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { - BinarySensorInfo: "binary_sensor", - ButtonInfo: "button", - CameraInfo: "camera", - ClimateInfo: "climate", - CoverInfo: "cover", - FanInfo: "fan", - LightInfo: "light", - LockInfo: "lock", - MediaPlayerInfo: "media_player", - NumberInfo: "number", - SelectInfo: "select", - SensorInfo: "sensor", - SwitchInfo: "switch", - TextSensorInfo: "sensor", + BinarySensorInfo: Platform.BINARY_SENSOR, + ButtonInfo: Platform.BUTTON, + CameraInfo: Platform.CAMERA, + ClimateInfo: Platform.CLIMATE, + CoverInfo: Platform.COVER, + FanInfo: Platform.FAN, + LightInfo: Platform.LIGHT, + LockInfo: Platform.LOCK, + MediaPlayerInfo: Platform.MEDIA_PLAYER, + NumberInfo: Platform.NUMBER, + SelectInfo: Platform.SELECT, + SensorInfo: Platform.SENSOR, + SwitchInfo: Platform.SWITCH, + TextSensorInfo: Platform.SENSOR, } @@ -63,7 +66,7 @@ class RuntimeEntryData: entry_id: str client: APIClient store: Store - state: dict[str, dict[int, EntityState]] = field(default_factory=dict) + state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) # A second list of EntityInfo objects @@ -78,18 +81,13 @@ class RuntimeEntryData: api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + state_subscriptions: dict[ + tuple[type[EntityState], int], Callable[[], None] + ] = field(default_factory=dict) loaded_platforms: set[str] = field(default_factory=set) platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _storage_contents: dict[str, Any] | None = None - @callback - def async_update_entity( - self, hass: HomeAssistant, component_key: str, key: int - ) -> None: - """Schedule the update of an entity.""" - signal = f"esphome_{self.entry_id}_update_{component_key}_{key}" - async_dispatcher_send(hass, signal) - @callback def async_remove_entity( self, hass: HomeAssistant, component_key: str, key: int @@ -130,10 +128,32 @@ class RuntimeEntryData: async_dispatcher_send(hass, signal, infos) @callback - def async_update_state(self, hass: HomeAssistant, state: EntityState) -> None: - """Distribute an update of state information to all platforms.""" - signal = f"esphome_{self.entry_id}_on_state" - async_dispatcher_send(hass, signal, state) + def async_subscribe_state_update( + self, + state_type: type[EntityState], + state_key: int, + entity_callback: Callable[[], None], + ) -> Callable[[], None]: + """Subscribe to state updates.""" + + def _unsubscribe() -> None: + self.state_subscriptions.pop((state_type, state_key)) + + self.state_subscriptions[(state_type, state_key)] = entity_callback + return _unsubscribe + + @callback + def async_update_state(self, state: EntityState) -> None: + """Distribute an update of state information to the target.""" + subscription_key = (type(state), state.key) + self.state[type(state)][state.key] = state + _LOGGER.debug( + "Dispatching update with key %s: %s", + subscription_key, + state, + ) + if subscription_key in self.state_subscriptions: + self.state_subscriptions[subscription_key]() @callback def async_update_device_state(self, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index f2440465e77..41d7e418673 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -67,8 +67,11 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): api_version = self._api_version return api_version.major == 1 and api_version.minor > 3 - async def async_set_percentage(self, percentage: int | None) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" + await self._async_set_percentage(percentage) + + async def _async_set_percentage(self, percentage: int | None) -> None: if percentage == 0: await self.async_turn_off() return @@ -95,7 +98,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): **kwargs: Any, ) -> None: """Turn on the fan.""" - await self.async_set_percentage(percentage) + await self._async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b89671c6f90..a8a76c2b0c8 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -5,6 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/esphome", "requirements": ["aioesphomeapi==10.10.0"], "zeroconf": ["_esphomelib._tcp.local."], + "dhcp": [{ "registered_devices": true }], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], "iot_class": "local_push", diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index be27779437d..bbca463a908 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -52,22 +52,22 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): """A number implementation for esphome.""" @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return super()._static_info.min_value @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return super()._static_info.max_value @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return super()._static_info.step @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return super()._static_info.unit_of_measurement @@ -79,7 +79,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): return NumberMode.AUTO @esphome_state_property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the entity.""" if math.isnan(self._state.state): return None @@ -87,6 +87,6 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): return None return self._state.state - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await self._client.number_command(self._static_info.key, value) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 36cf2ac456e..6c334291ee5 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,4 +1,6 @@ """Config flow for ezviz.""" +from __future__ import annotations + import logging from pyezviz.client import EzvizClient @@ -12,7 +14,7 @@ from pyezviz.exceptions import ( from pyezviz.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -164,7 +166,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler: """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) @@ -311,7 +313,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): class EzvizOptionsFlowHandler(OptionsFlow): """Handle Ezviz client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/ezviz/translations/bg.json b/homeassistant/components/ezviz/translations/bg.json index e198a4e53f4..702e3b80001 100644 --- a/homeassistant/components/ezviz/translations/bg.json +++ b/homeassistant/components/ezviz/translations/bg.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{serial}", "step": { "user": { "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Ezviz Cloud" diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index ea8848a5af2..e205e1a66cc 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -3,7 +3,6 @@ from http import HTTPStatus import json import logging -from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol @@ -74,7 +73,7 @@ class FacebookNotificationService(BaseNotificationService): BASE_URL, data=json.dumps(body), params=payload, - headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + headers={"Content-Type": CONTENT_TYPE_JSON}, timeout=10, ) if resp.status_code != HTTPStatus.OK: diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 5eb3bedabd5..8f6585f6535 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -7,7 +7,7 @@ from enum import IntEnum import functools as ft import logging import math -from typing import final +from typing import Any, final import voluptuous as vol @@ -80,9 +80,11 @@ class NotValidPresetModeError(ValueError): @bind_hass -def is_on(hass, entity_id: str) -> bool: +def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the fans are on based on the statemachine.""" - return hass.states.get(entity_id).state == STATE_ON + entity = hass.states.get(entity_id) + assert entity + return entity.state == STATE_ON async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -235,7 +237,7 @@ class FanEntity(ToggleEntity): """Set new preset mode.""" await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) - def _valid_preset_mode_or_raise(self, preset_mode): + def _valid_preset_mode_or_raise(self, preset_mode: str) -> None: """Raise NotValidPresetModeError on invalid preset_mode.""" preset_modes = self.preset_modes if not preset_modes or preset_mode not in preset_modes: @@ -247,7 +249,7 @@ class FanEntity(ToggleEntity): """Set the direction of the fan.""" raise NotImplementedError() - async def async_set_direction(self, direction: str): + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" await self.hass.async_add_executor_job(self.set_direction, direction) @@ -255,7 +257,7 @@ class FanEntity(ToggleEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" raise NotImplementedError() @@ -264,7 +266,7 @@ class FanEntity(ToggleEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.hass.async_add_executor_job( @@ -280,12 +282,12 @@ class FanEntity(ToggleEntity): """Oscillate the fan.""" raise NotImplementedError() - async def async_oscillate(self, oscillating: bool): + async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" await self.hass.async_add_executor_job(self.oscillate, oscillating) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the entity is on.""" return ( self.percentage is not None and self.percentage > 0 @@ -321,7 +323,7 @@ class FanEntity(ToggleEntity): return self._attr_oscillating @property - def capability_attributes(self): + def capability_attributes(self) -> dict[str, list[str] | None]: """Return capability attributes.""" attrs = {} @@ -335,7 +337,7 @@ class FanEntity(ToggleEntity): @final @property - def state_attributes(self) -> dict: + def state_attributes(self) -> dict[str, float | str | None]: """Return optional state attributes.""" data: dict[str, float | str | None] = {} supported_features = self.supported_features diff --git a/homeassistant/components/fan/translations/zh-Hans.json b/homeassistant/components/fan/translations/zh-Hans.json index 13b6917f4ad..0bcc6440627 100644 --- a/homeassistant/components/fan/translations/zh-Hans.json +++ b/homeassistant/components/fan/translations/zh-Hans.json @@ -1,6 +1,7 @@ { "device_automation": { "action_type": { + "toggle": "\u5207\u6362 {entity_name} \u5f00\u5173", "turn_off": "\u5173\u95ed {entity_name}", "turn_on": "\u6253\u5f00 {entity_name}" }, @@ -9,6 +10,7 @@ "is_on": "{entity_name} \u5df2\u5f00\u542f" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" } diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index d06aeec4932..b3f1a916012 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -218,7 +218,7 @@ class StoredData: with self._lock, open(self._data_file, "rb") as myfile: self._data = pickle.load(myfile) or {} self._cache_outdated = False - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.error( "Error loading data from pickled file %s", self._data_file ) @@ -240,6 +240,6 @@ class StoredData: ) try: pickle.dump(self._data, myfile) - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.error("Error saving pickled data to %s", self._data_file) self._cache_outdated = True diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index fdb0f894a40..bd7c3a09ec0 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -490,6 +490,9 @@ class FibaroDevice(Entity): self.ha_id = fibaro_device.ha_id self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str + # propagate hidden attribute set in fibaro home center to HA + if "visible" in fibaro_device and fibaro_device.visible is False: + self._attr_entity_registry_visible_default = False async def async_added_to_hass(self): """Call when entity is added to hass.""" diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 3c423dc0ce8..359869efc25 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Fibaro binary sensors.""" from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import ( ENTITY_ID_FORMAT, BinarySensorDeviceClass, @@ -49,7 +51,7 @@ async def async_setup_entry( class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): """Representation of a Fibaro Binary Sensor.""" - def __init__(self, fibaro_device): + def __init__(self, fibaro_device: Any) -> None: """Initialize the binary_sensor.""" super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) @@ -62,6 +64,6 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): self._attr_device_class = SENSOR_TYPES[stype][2] self._attr_icon = SENSOR_TYPES[stype][1] - def update(self): + def update(self) -> None: """Get the latest data and update the state.""" self._attr_is_on = self.current_binary_state diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py index f528fd8a184..b0ea05e49e1 100644 --- a/homeassistant/components/fibaro/config_flow.py +++ b/homeassistant/components/fibaro/config_flow.py @@ -69,7 +69,7 @@ class FibaroConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" else: await self.async_set_unique_id(info["serial_number"]) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry(title=info["name"], data=user_input) return self.async_show_form( diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e898cd7ead9..364cbcf39cb 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -1,6 +1,8 @@ """Support for Fibaro cover - curtains, rollershutters etc.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -70,49 +72,54 @@ class FibaroCover(FibaroDevice, CoverEntity): return False @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. 0 is closed, 100 is open.""" return self.bound(self.level) @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt position for venetian blinds.""" return self.bound(self.level2) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.set_level(kwargs.get(ATTR_POSITION)) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.set_level2(kwargs.get(ATTR_TILT_POSITION)) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._is_open_close_only(): + if ( + "state" not in self.fibaro_device.properties + or self.fibaro_device.properties.state.lower() == "unknown" + ): + return None return self.fibaro_device.properties.state.lower() == "closed" if self.current_cover_position is None: return None return self.current_cover_position == 0 - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.action("open") - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self.action("close") - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" self.set_level2(100) - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover.""" self.set_level2(0) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self.action("stop") diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index aed7017ba61..e8fc4ca7180 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -1,6 +1,10 @@ """Support for Fibaro locks.""" from __future__ import annotations +from typing import Any + +from fiblary3.client.v4.models import DeviceModel, SceneModel + from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -31,27 +35,21 @@ async def async_setup_entry( class FibaroLock(FibaroDevice, LockEntity): """Representation of a Fibaro Lock.""" - def __init__(self, fibaro_device): + def __init__(self, fibaro_device: DeviceModel | SceneModel) -> None: """Initialize the Fibaro device.""" - self._state = False super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the device.""" self.action("secure") - self._state = True + self._attr_is_locked = True - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self.action("unsecure") - self._state = False + self._attr_is_locked = False - @property - def is_locked(self): - """Return true if device is locked.""" - return self._state - - def update(self): + def update(self) -> None: """Update device state.""" - self._state = self.current_binary_state + self._attr_is_locked = self.current_binary_state diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index acaa97ee2a2..88d6113ebb9 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -2,11 +2,13 @@ from __future__ import annotations from contextlib import suppress +from typing import Any from homeassistant.components.sensor import ( ENTITY_ID_FORMAT, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -27,49 +29,73 @@ from homeassistant.util import convert from . import FIBARO_DEVICES, FibaroDevice from .const import DOMAIN -SENSOR_TYPES = { - "com.fibaro.temperatureSensor": [ - "Temperature", - None, - None, - SensorDeviceClass.TEMPERATURE, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.smokeSensor": [ - "Smoke", - CONCENTRATION_PARTS_PER_MILLION, - "mdi:fire", - None, - None, - ], - "CO2": [ - "CO2", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.humiditySensor": [ - "Humidity", - PERCENTAGE, - None, - SensorDeviceClass.HUMIDITY, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.lightSensor": [ - "Light", - LIGHT_LUX, - None, - SensorDeviceClass.ILLUMINANCE, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.energyMeter": [ - "Energy", - ENERGY_KILO_WATT_HOUR, - None, - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - ], +# List of known sensors which represents a fibaro device +MAIN_SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "com.fibaro.temperatureSensor": SensorEntityDescription( + key="com.fibaro.temperatureSensor", + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.smokeSensor": SensorEntityDescription( + key="com.fibaro.smokeSensor", + name="Smoke", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:fire", + ), + "CO2": SensorEntityDescription( + key="CO2", + name="CO2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.humiditySensor": SensorEntityDescription( + key="com.fibaro.humiditySensor", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.lightSensor": SensorEntityDescription( + key="com.fibaro.lightSensor", + name="Light", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.energyMeter": SensorEntityDescription( + key="com.fibaro.energyMeter", + name="Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +} + +# List of additional sensors which are created based on a property +# The key is the property name +ADDITIONAL_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="energy", + name="Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power", + name="Power", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +FIBARO_TO_HASS_UNIT: dict[str, str] = { + "lux": LIGHT_LUX, + "C": TEMP_CELSIUS, + "F": TEMP_FAHRENHEIT, } @@ -80,14 +106,18 @@ async def async_setup_entry( ) -> None: """Set up the Fibaro controller devices.""" entities: list[SensorEntity] = [] + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][Platform.SENSOR]: - entities.append(FibaroSensor(device)) + 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]: - if "energy" in device.interfaces: - entities.append(FibaroEnergySensor(device)) - if "power" in device.interfaces: - entities.append(FibaroPowerSensor(device)) + for entity_description in ADDITIONAL_SENSOR_TYPES: + if entity_description.key in device.properties: + entities.append(FibaroAdditionalSensor(device, entity_description)) async_add_entities(entities, True) @@ -95,97 +125,51 @@ async def async_setup_entry( class FibaroSensor(FibaroDevice, SensorEntity): """Representation of a Fibaro Sensor.""" - def __init__(self, fibaro_device): + def __init__( + self, fibaro_device: Any, entity_description: SensorEntityDescription | None + ) -> None: """Initialize the sensor.""" - self.current_value = None - self.last_changed_time = None super().__init__(fibaro_device) + if entity_description is not None: + self.entity_description = entity_description self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - if fibaro_device.type in SENSOR_TYPES: - self._unit = SENSOR_TYPES[fibaro_device.type][1] - self._icon = SENSOR_TYPES[fibaro_device.type][2] - self._device_class = SENSOR_TYPES[fibaro_device.type][3] - self._attr_state_class = SENSOR_TYPES[fibaro_device.type][4] - else: - self._unit = None - self._icon = None - self._device_class = None + + # Map unit if it was not defined in the entity description + # or there is no entity description at all with suppress(KeyError, ValueError): - if not self._unit: - if self.fibaro_device.properties.unit == "lux": - self._unit = LIGHT_LUX - elif self.fibaro_device.properties.unit == "C": - self._unit = TEMP_CELSIUS - elif self.fibaro_device.properties.unit == "F": - self._unit = TEMP_FAHRENHEIT - else: - self._unit = self.fibaro_device.properties.unit - - @property - def native_value(self): - """Return the state of the sensor.""" - return self.current_value - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class + if not self.native_unit_of_measurement: + self._attr_native_unit_of_measurement = FIBARO_TO_HASS_UNIT.get( + fibaro_device.properties.unit, fibaro_device.properties.unit + ) def update(self): """Update the state.""" with suppress(KeyError, ValueError): - self.current_value = float(self.fibaro_device.properties.value) + self._attr_native_value = float(self.fibaro_device.properties.value) -class FibaroEnergySensor(FibaroDevice, SensorEntity): - """Representation of a Fibaro Energy Sensor.""" +class FibaroAdditionalSensor(FibaroDevice, SensorEntity): + """Representation of a Fibaro Additional Sensor.""" - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - - def __init__(self, fibaro_device): + def __init__( + self, fibaro_device: Any, entity_description: SensorEntityDescription + ) -> None: """Initialize the sensor.""" super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(f"{self.ha_id}_energy") - self._attr_name = f"{fibaro_device.friendly_name} Energy" - self._attr_unique_id = f"{fibaro_device.unique_id_str}_energy" + self.entity_description = entity_description - def update(self): + # To differentiate additional sensors from main sensors they need + # to get different names and ids + self.entity_id = ENTITY_ID_FORMAT.format( + f"{self.ha_id}_{entity_description.key}" + ) + self._attr_name = f"{fibaro_device.friendly_name} {entity_description.name}" + self._attr_unique_id = f"{fibaro_device.unique_id_str}_{entity_description.key}" + + def update(self) -> None: """Update the state.""" with suppress(KeyError, ValueError): self._attr_native_value = convert( - self.fibaro_device.properties.energy, float - ) - - -class FibaroPowerSensor(FibaroDevice, SensorEntity): - """Representation of a Fibaro Power Sensor.""" - - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = POWER_WATT - - def __init__(self, fibaro_device): - """Initialize the sensor.""" - super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(f"{self.ha_id}_power") - self._attr_name = f"{fibaro_device.friendly_name} Power" - self._attr_unique_id = f"{fibaro_device.unique_id_str}_power" - - def update(self): - """Update the state.""" - with suppress(KeyError, ValueError): - self._attr_native_value = convert( - self.fibaro_device.properties.power, float + self.fibaro_device.properties[self.entity_description.key], + float, ) diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index fe2b35866b0..66aad4d673b 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -1,6 +1,8 @@ """Support for Fibaro switches.""" from __future__ import annotations +from typing import Any + from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -31,27 +33,21 @@ async def async_setup_entry( class FibaroSwitch(FibaroDevice, SwitchEntity): """Representation of a Fibaro Switch.""" - def __init__(self, fibaro_device): + def __init__(self, fibaro_device: Any) -> None: """Initialize the Fibaro device.""" - self._state = False super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn device on.""" self.call_turn_on() - self._state = True + self._attr_is_on = True - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" self.call_turn_off() - self._state = False + self._attr_is_on = False - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - def update(self): + def update(self) -> None: """Update device state.""" - self._state = self.current_binary_state + self._attr_is_on = self.current_binary_state diff --git a/homeassistant/components/fibaro/translations/es.json b/homeassistant/components/fibaro/translations/es.json index 00a7eeb8ece..99f29f3bee5 100644 --- a/homeassistant/components/fibaro/translations/es.json +++ b/homeassistant/components/fibaro/translations/es.json @@ -1,11 +1,17 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { - "cannot_connect": "Fall\u00f3 la conexi\u00f3n" + "cannot_connect": "Fall\u00f3 la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "user": { "data": { + "import_plugins": "\u00bfImportar entidades desde los plugins de fibaro?", "password": "Contrase\u00f1a", "url": "URL en el format http://HOST/api/", "username": "Usuario" diff --git a/homeassistant/components/fibaro/translations/sv.json b/homeassistant/components/fibaro/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/fibaro/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/file/manifest.json b/homeassistant/components/file/manifest.json index 8688ed7939c..2283e74a5e7 100644 --- a/homeassistant/components/file/manifest.json +++ b/homeassistant/components/file/manifest.json @@ -3,5 +3,6 @@ "name": "File", "documentation": "https://www.home-assistant.io/integrations/file", "codeowners": ["@fabaff"], - "iot_class": "local_polling" + "iot_class": "local_polling", + "requirements": ["file-read-backwards==2.0.0"] } diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index e69a7701eb9..8c0966f30bd 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import os +from file_read_backwards import FileReadBackwards import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -77,9 +78,10 @@ class FileSensor(SensorEntity): def update(self): """Get the latest entry from a file and updates the state.""" try: - with open(self._file_path, encoding="utf-8") as file_data: + with FileReadBackwards(self._file_path, encoding="utf-8") as file_data: for line in file_data: data = line + break data = data.strip() except (IndexError, FileNotFoundError, IsADirectoryError, UnboundLocalError): _LOGGER.warning( diff --git a/homeassistant/components/filesize/translations/es.json b/homeassistant/components/filesize/translations/es.json index e2bc079b961..ee030e86cf9 100644 --- a/homeassistant/components/filesize/translations/es.json +++ b/homeassistant/components/filesize/translations/es.json @@ -1,7 +1,19 @@ { "config": { + "abort": { + "already_configured": "El servicio ya est\u00e1 configurado" + }, "error": { - "not_allowed": "Ruta no permitida" + "not_allowed": "Ruta no permitida", + "not_valid": "La ruta no es v\u00e1lida" + }, + "step": { + "user": { + "data": { + "file_path": "Ruta al archivo" + } + } } - } + }, + "title": "Tama\u00f1o del archivo" } \ No newline at end of file diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json index ede1025a6db..11d673a2837 100644 --- a/homeassistant/components/fints/manifest.json +++ b/homeassistant/components/fints/manifest.json @@ -2,8 +2,8 @@ "domain": "fints", "name": "FinTS", "documentation": "https://www.home-assistant.io/integrations/fints", - "requirements": ["fints==1.0.1"], + "requirements": ["fints==3.1.0"], "codeowners": [], - "iot_class": "local_push", + "iot_class": "cloud_polling", "loggers": ["fints", "mt_940", "sepaxml"] } diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 4b6cb336bde..2e2ccd8e6b6 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -7,7 +7,6 @@ import logging from typing import Any from fints.client import FinTS3PinTanClient -from fints.dialog import FinTSDialogError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -128,6 +127,9 @@ class FinTsClient: As the fints library is stateless, there is not benefit in caching the client objects. If that ever changes, consider caching the client object and also think about potential concurrency problems. + + Note: As of version 2, the fints library is not stateless anymore. + This should be considered when reworking this integration. """ return FinTS3PinTanClient( @@ -140,24 +142,22 @@ class FinTsClient: def detect_accounts(self): """Identify the accounts of the bank.""" + bank = self.client + accounts = bank.get_sepa_accounts() + account_types = { + x["iban"]: x["type"] + for x in bank.get_information()["accounts"] + if x["iban"] is not None + } + balance_accounts = [] holdings_accounts = [] - for account in self.client.get_sepa_accounts(): - try: - self.client.get_balance(account) + for account in accounts: + account_type = account_types[account.iban] + if 1 <= account_type <= 9: # 1-9 is balance account balance_accounts.append(account) - except IndexError: - # account is not a balance account. - pass - except FinTSDialogError: - # account is not a balance account. - pass - try: - self.client.get_holdings(account) + elif 30 <= account_type <= 39: # 30-39 is holdings account holdings_accounts.append(account) - except FinTSDialogError: - # account is not a holdings account. - pass return balance_accounts, holdings_accounts diff --git a/homeassistant/components/fireservicerota/translations/sv.json b/homeassistant/components/fireservicerota/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index b4a9ada2c27..8aa4cfb836c 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -1,11 +1,13 @@ """Config flow to configure firmata component.""" import logging +from typing import Any from pymata_express.pymata_express_serial import serial from homeassistant import config_entries from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult from .board import get_board from .const import CONF_SERIAL_PORT, DOMAIN @@ -18,7 +20,7 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config: dict): + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Import a firmata board as a config entry. This flow is triggered by `async_setup` for configured boards. diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json index 87f2370aace..4d058f82e22 100644 --- a/homeassistant/components/fixer/manifest.json +++ b/homeassistant/components/fixer/manifest.json @@ -3,7 +3,7 @@ "name": "Fixer", "documentation": "https://www.home-assistant.io/integrations/fixer", "requirements": ["fixerio==1.0.0a0"], - "codeowners": ["@fabaff"], + "codeowners": [], "iot_class": "cloud_polling", "loggers": ["fixerio"] } diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index fcd95090400..e372d540f54 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -1,6 +1,8 @@ """Support for Fjäråskupan fans.""" from __future__ import annotations +from typing import Any + from fjaraskupan import ( COMMAND_AFTERCOOKINGTIMERAUTO, COMMAND_AFTERCOOKINGTIMERMANUAL, @@ -93,9 +95,9 @@ class Fan(CoordinatorEntity[Coordinator], FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" @@ -133,7 +135,7 @@ class Fan(CoordinatorEntity[Coordinator], FanEntity): else: raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._device.send_command(COMMAND_STOP_FAN) self.coordinator.async_set_updated_data(self._device.state) diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index 6314b9c9cc1..511d97cbed8 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -34,11 +34,11 @@ async def async_setup_entry( class PeriodicVentingTime(CoordinatorEntity[Coordinator], NumberEntity): """Periodic Venting.""" - _attr_max_value: float = 59 - _attr_min_value: float = 0 - _attr_step: float = 1 + _attr_native_max_value: float = 59 + _attr_native_min_value: float = 0 + _attr_native_step: float = 1 _attr_entity_category = EntityCategory.CONFIG - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__( self, @@ -54,13 +54,13 @@ class PeriodicVentingTime(CoordinatorEntity[Coordinator], NumberEntity): self._attr_name = f"{device_info['name']} Periodic Venting" @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" if data := self.coordinator.data: return data.periodic_venting return None - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self._device.send_periodic_venting(int(value)) self.coordinator.async_set_updated_data(self._device.state) diff --git a/homeassistant/components/flick_electric/translations/sv.json b/homeassistant/components/flick_electric/translations/sv.json index 23c825f256f..2957bed953a 100644 --- a/homeassistant/components/flick_electric/translations/sv.json +++ b/homeassistant/components/flick_electric/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_auth": "Ogiltig autentisering" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index e78031bd5cb..9cf788d7170 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ELECTRIC_POTENTIAL_MILLIVOLT, TEMP_CELSIUS @@ -20,17 +21,20 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Chlorine", native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, icon="mdi:pool", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ph", name="pH", icon="mdi:pool", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temperature", name="Water Temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="date_time", @@ -42,6 +46,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Red OX", native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, icon="mdi:pool", + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/flo/translations/sv.json b/homeassistant/components/flo/translations/sv.json new file mode 100644 index 00000000000..78879942876 --- /dev/null +++ b/homeassistant/components/flo/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index 6d9554f42c0..049b702dc3c 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -1,6 +1,8 @@ """Config flow for flume integration.""" +from collections.abc import Mapping import logging import os +from typing import Any from pyflume import FlumeAuth, FlumeDeviceList from requests.exceptions import RequestException @@ -13,6 +15,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.data_entry_flow import FlowResult from .const import BASE_TOKEN_FILENAME, DOMAIN @@ -103,7 +106,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._reauth_unique_id = self.context["unique_id"] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/flume/translations/bg.json b/homeassistant/components/flume/translations/bg.json index 6eca91e8ed2..14aa8f088f3 100644 --- a/homeassistant/components/flume/translations/bg.json +++ b/homeassistant/components/flume/translations/bg.json @@ -9,6 +9,9 @@ "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { + "reauth_confirm": { + "description": "\u041f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0437\u0430 {username} \u0432\u0435\u0447\u0435 \u043d\u0435 \u0435 \u0432\u0430\u043b\u0438\u0434\u043d\u0430." + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/flunearyou/translations/bg.json b/homeassistant/components/flunearyou/translations/bg.json new file mode 100644 index 00000000000..360abac2642 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index 06f706aee21..65c8a955dcf 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -97,18 +97,18 @@ class FluxSpeedNumber( ): """Defines a flux_led speed number.""" - _attr_min_value = 1 - _attr_max_value = 100 - _attr_step = 1 + _attr_native_min_value = 1 + _attr_native_max_value = 100 + _attr_native_step = 1 _attr_mode = NumberMode.SLIDER _attr_icon = "mdi:speedometer" @property - def value(self) -> float: + def native_value(self) -> float: """Return the effect speed.""" return cast(float, self._device.speed) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the flux speed value.""" current_effect = self._device.effect new_speed = int(value) @@ -130,8 +130,8 @@ class FluxConfigNumber( """Base class for flux config numbers.""" _attr_entity_category = EntityCategory.CONFIG - _attr_min_value = 1 - _attr_step = 1 + _attr_native_min_value = 1 + _attr_native_step = 1 _attr_mode = NumberMode.BOX def __init__( @@ -153,18 +153,18 @@ class FluxConfigNumber( logger=_LOGGER, cooldown=DEBOUNCE_TIME, immediate=False, - function=self._async_set_value, + function=self._async_set_native_value, ) await super().async_added_to_hass() - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the value.""" self._pending_value = int(value) assert self._debouncer is not None await self._debouncer.async_call() @abstractmethod - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Call on debounce to set the value.""" def _pixels_and_segments_fit_in_music_mode(self) -> bool: @@ -189,19 +189,19 @@ class FluxPixelsPerSegmentNumber(FluxConfigNumber): _attr_icon = "mdi:dots-grid" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" return min( PIXELS_PER_SEGMENT_MAX, int(PIXELS_MAX / (self._device.segments or 1)) ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the pixels per segment.""" assert self._device.pixels_per_segment is not None return self._device.pixels_per_segment - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the pixels per segment.""" assert self._pending_value is not None await self._device.async_set_device_config( @@ -215,7 +215,7 @@ class FluxSegmentsNumber(FluxConfigNumber): _attr_icon = "mdi:segment" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" assert self._device.pixels_per_segment is not None return min( @@ -223,12 +223,12 @@ class FluxSegmentsNumber(FluxConfigNumber): ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the segments.""" assert self._device.segments is not None return self._device.segments - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the segments.""" assert self._pending_value is not None await self._device.async_set_device_config(segments=self._pending_value) @@ -249,7 +249,7 @@ class FluxMusicPixelsPerSegmentNumber(FluxMusicNumber): _attr_icon = "mdi:dots-grid" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" assert self._device.music_segments is not None return min( @@ -258,12 +258,12 @@ class FluxMusicPixelsPerSegmentNumber(FluxMusicNumber): ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the music pixels per segment.""" assert self._device.music_pixels_per_segment is not None return self._device.music_pixels_per_segment - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the music pixels per segment.""" assert self._pending_value is not None await self._device.async_set_device_config( @@ -277,7 +277,7 @@ class FluxMusicSegmentsNumber(FluxMusicNumber): _attr_icon = "mdi:segment" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" assert self._device.pixels_per_segment is not None return min( @@ -286,12 +286,12 @@ class FluxMusicSegmentsNumber(FluxMusicNumber): ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the music segments.""" assert self._device.music_segments is not None return self._device.music_segments - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the music segments.""" assert self._pending_value is not None await self._device.async_set_device_config(music_segments=self._pending_value) diff --git a/homeassistant/components/flux_led/translations/fr.json b/homeassistant/components/flux_led/translations/fr.json index baea6899999..691c470c6aa 100644 --- a/homeassistant/components/flux_led/translations/fr.json +++ b/homeassistant/components/flux_led/translations/fr.json @@ -11,7 +11,7 @@ "flow_title": "{model} {id} ( {ipaddr} )", "step": { "discovery_confirm": { - "description": "Voulez-vous configurer {model} {id} ( {ipaddr} )\u00a0?" + "description": "Voulez-vous configurer {model} {id} ({ipaddr})\u00a0?" }, "user": { "data": { diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index e718c3d3bf2..cd979d51457 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -63,19 +63,30 @@ def create_event_handler(patterns, hass): super().__init__(patterns) self.hass = hass - def process(self, event): + def process(self, event, moved=False): """On Watcher event, fire HA event.""" _LOGGER.debug("process(%s)", event) if not event.is_directory: folder, file_name = os.path.split(event.src_path) + fireable = { + "event_type": event.event_type, + "path": event.src_path, + "file": file_name, + "folder": folder, + } + + if moved: + dest_folder, dest_file_name = os.path.split(event.dest_path) + fireable.update( + { + "dest_path": event.dest_path, + "dest_file": dest_file_name, + "dest_folder": dest_folder, + } + ) self.hass.bus.fire( DOMAIN, - { - "event_type": event.event_type, - "path": event.src_path, - "file": file_name, - "folder": folder, - }, + fireable, ) def on_modified(self, event): @@ -84,7 +95,7 @@ def create_event_handler(patterns, hass): def on_moved(self, event): """File moved.""" - self.process(event) + self.process(event, moved=True) def on_created(self, event): """File created.""" diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index f7562633ba0..64bfbb3df37 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,7 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher", - "requirements": ["watchdog==2.1.8"], + "requirements": ["watchdog==2.1.9"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_polling", diff --git a/homeassistant/components/forecast_solar/translations/sv.json b/homeassistant/components/forecast_solar/translations/sv.json new file mode 100644 index 00000000000..fceb441190b --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "modules power": "Total maxeffekt (Watt) p\u00e5 dina solpaneler" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index e3cf6fc7c1d..f9282dfc464 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -46,7 +46,7 @@ TEST_CONNECTION_ERROR_DICT = { class ForkedDaapdOptionsFlowHandler(config_entries.OptionsFlow): """Handle a forked-daapd options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry @@ -110,7 +110,9 @@ class ForkedDaapdFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> ForkedDaapdOptionsFlowHandler: """Return options flow handler.""" return ForkedDaapdOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/foscam/translations/sv.json b/homeassistant/components/foscam/translations/sv.json new file mode 100644 index 00000000000..78879942876 --- /dev/null +++ b/homeassistant/components/foscam/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/bg.json b/homeassistant/components/freebox/translations/bg.json index c8526b8367d..9a63019cd8a 100644 --- a/homeassistant/components/freebox/translations/bg.json +++ b/homeassistant/components/freebox/translations/bg.json @@ -1,7 +1,8 @@ { "config": { "error": { - "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e" + "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index ab3914519dd..ebb8a98b4b1 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -1,5 +1,6 @@ """Support for Freedompro cover.""" import json +from typing import Any from pyfreedompro import put_state @@ -96,23 +97,21 @@ class Device(CoordinatorEntity, CoverEntity): await super().async_added_to_hass() self._handle_coordinator_update() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.async_set_cover_position(position=100) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.async_set_cover_position(position=0) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Async function to set position to cover.""" - payload = {} - payload["position"] = kwargs[ATTR_POSITION] - payload = json.dumps(payload) + payload = {"position": kwargs[ATTR_POSITION]} await put_state( self._session, self._api_key, self.unique_id, - payload, + json.dumps(payload), ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index b7758813865..443a1375f24 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +from typing import Any from pyfreedompro import put_state @@ -60,7 +61,7 @@ class FreedomproFan(CoordinatorEntity, FanEntity): return self._attr_is_on @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed percentage.""" return self._attr_percentage @@ -87,31 +88,34 @@ class FreedomproFan(CoordinatorEntity, FanEntity): await super().async_added_to_hass() self._handle_coordinator_update() - async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs): + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Async function to turn on the fan.""" payload = {"on": True} - payload = json.dumps(payload) await put_state( self._session, self._api_key, self.unique_id, - payload, + json.dumps(payload), ) await self.coordinator.async_request_refresh() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Async function to turn off the fan.""" payload = {"on": False} - payload = json.dumps(payload) await put_state( self._session, self._api_key, self.unique_id, - payload, + json.dumps(payload), ) await self.coordinator.async_request_refresh() - async def async_set_percentage(self, percentage: int): + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" rotation_speed = {"rotationSpeed": percentage} payload = json.dumps(rotation_speed) diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index 7dbc625966e..237ad50c053 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -1,5 +1,6 @@ """Support for Freedompro lock.""" import json +from typing import Any from pyfreedompro import put_state @@ -75,10 +76,10 @@ class Device(CoordinatorEntity, LockEntity): await super().async_added_to_hass() self._handle_coordinator_update() - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Async function to lock the lock.""" - payload = {"lock": 1} - payload = json.dumps(payload) + payload_dict = {"lock": 1} + payload = json.dumps(payload_dict) await put_state( self._session, self._api_key, @@ -87,10 +88,10 @@ class Device(CoordinatorEntity, LockEntity): ) await self.coordinator.async_request_refresh() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Async function to unlock the lock.""" - payload = {"lock": 0} - payload = json.dumps(payload) + payload_dict = {"lock": 0} + payload = json.dumps(payload_dict) await put_state( self._session, self._api_key, diff --git a/homeassistant/components/freedompro/translations/sv.json b/homeassistant/components/freedompro/translations/sv.json new file mode 100644 index 00000000000..9feab4808f7 --- /dev/null +++ b/homeassistant/components/freedompro/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 3e6961f585d..ddc09cb73a9 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the FRITZ!Box Tools integration.""" from __future__ import annotations +from collections.abc import Mapping import ipaddress import logging import socket @@ -230,13 +231,13 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_create_entry() - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle flow upon an API authentication error.""" self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - self._host = data[CONF_HOST] - self._port = data[CONF_PORT] - self._username = data[CONF_USERNAME] - self._password = data[CONF_PASSWORD] + self._host = entry_data[CONF_HOST] + self._port = entry_data[CONF_PORT] + self._username = entry_data[CONF_USERNAME] + self._password = entry_data[CONF_PASSWORD] return await self.async_step_reauth_confirm() def _show_setup_form_reauth_confirm( diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 5eb210d2091..e5828ba76cf 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -2,7 +2,7 @@ "domain": "fritz", "name": "AVM FRITZ!Box Tools", "documentation": "https://www.home-assistant.io/integrations/fritz", - "requirements": ["fritzconnection==1.8.0", "xmltodict==0.12.0"], + "requirements": ["fritzconnection==1.8.0", "xmltodict==0.13.0"], "dependencies": ["network"], "codeowners": ["@mammuth", "@AaronDavidSchneider", "@chemelli74", "@mib1185"], "config_flow": true, diff --git a/homeassistant/components/fritz/translations/bg.json b/homeassistant/components/fritz/translations/bg.json index 43f9aaf5357..b699cec829c 100644 --- a/homeassistant/components/fritz/translations/bg.json +++ b/homeassistant/components/fritz/translations/bg.json @@ -3,6 +3,7 @@ "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/fritz/translations/es.json b/homeassistant/components/fritz/translations/es.json index 964db5b5325..f1386a9e87f 100644 --- a/homeassistant/components/fritz/translations/es.json +++ b/homeassistant/components/fritz/translations/es.json @@ -10,7 +10,8 @@ "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "upnp_not_configured": "Falta la configuraci\u00f3n de UPnP en el dispositivo." }, "flow_title": "FRITZ!Box Tools: {name}", "step": { @@ -46,7 +47,8 @@ "step": { "init": { "data": { - "consider_home": "Segundos para considerar un dispositivo en 'casa'" + "consider_home": "Segundos para considerar un dispositivo en 'casa'", + "old_discovery": "Habilitar m\u00e9todo de descubrimiento antiguo" } } } diff --git a/homeassistant/components/fritz/translations/sv.json b/homeassistant/components/fritz/translations/sv.json new file mode 100644 index 00000000000..02cd1e39b0e --- /dev/null +++ b/homeassistant/components/fritz/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index bf290cb28f7..b4e86b92568 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -1,6 +1,7 @@ """Config flow for AVM FRITZ!SmartHome.""" from __future__ import annotations +from collections.abc import Mapping import ipaddress from typing import Any from urllib.parse import urlparse @@ -175,14 +176,14 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry is not None self._entry = entry - self._host = data[CONF_HOST] - self._name = str(data[CONF_HOST]) - self._username = data[CONF_USERNAME] + self._host = entry_data[CONF_HOST] + self._name = str(entry_data[CONF_HOST]) + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/fritzbox/translations/sv.json b/homeassistant/components/fritzbox/translations/sv.json index 2d5586b6bc6..b611b3a9893 100644 --- a/homeassistant/components/fritzbox/translations/sv.json +++ b/homeassistant/components/fritzbox/translations/sv.json @@ -8,6 +8,11 @@ }, "description": "Do vill du konfigurera {name}?" }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd eller IP-adress", diff --git a/homeassistant/components/fritzbox_callmonitor/translations/bg.json b/homeassistant/components/fritzbox_callmonitor/translations/bg.json index fc2115d9ca0..ed2dd868df7 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/bg.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/bg.json @@ -1,6 +1,11 @@ { "config": { "step": { + "phonebook": { + "data": { + "phonebook": "\u0422\u0435\u043b\u0435\u0444\u043e\u043d\u0435\u043d \u0443\u043a\u0430\u0437\u0430\u0442\u0435\u043b" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/fritzbox_callmonitor/translations/sv.json b/homeassistant/components/fritzbox_callmonitor/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b3907143eb9..188ecb8ff98 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Iterator from functools import lru_cache -import json import logging import os import pathlib @@ -22,6 +21,7 @@ from homeassistant.const import CONF_MODE, CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType @@ -135,7 +135,7 @@ class Manifest: return self._serialized def _serialize(self) -> None: - self._serialized = json.dumps(self.manifest, sort_keys=True) + self._serialized = json_dumps_sorted(self.manifest) def update_key(self, key: str, val: str) -> None: """Add a keyval to the manifest.json.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7d07bbd543c..85ead380485 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220601.0"], + "requirements": ["home-assistant-frontend==20220706.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py new file mode 100644 index 00000000000..4638e63c2f2 --- /dev/null +++ b/homeassistant/components/frontier_silicon/const.py @@ -0,0 +1,6 @@ +"""Constants for the Frontier Silicon Media Player integration.""" + +DOMAIN = "frontier_silicon" + +DEFAULT_PIN = "1234" +DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 3eb982e8118..20092b941a9 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -2,7 +2,7 @@ "domain": "frontier_silicon", "name": "Frontier Silicon", "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", - "requirements": ["afsapi==0.0.4"], - "codeowners": [], - "iot_class": "local_push" + "requirements": ["afsapi==0.2.4"], + "codeowners": ["@wlcrs"], + "iot_class": "local_polling" } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 034762a09ac..61dc6e69726 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -3,8 +3,7 @@ from __future__ import annotations import logging -from afsapi import AFSAPI -import requests +from afsapi import AFSAPI, ConnectionError as FSConnectionError, PlayState import voluptuous as vol from homeassistant.components.media_player import ( @@ -20,25 +19,25 @@ from homeassistant.const import ( CONF_PORT, STATE_IDLE, STATE_OFF, + STATE_OPENING, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN -DEFAULT_PORT = 80 -DEFAULT_PASSWORD = "1234" +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PIN): cv.string, vol.Optional(CONF_NAME): cv.string, } ) @@ -52,8 +51,14 @@ async def async_setup_platform( ) -> None: """Set up the Frontier Silicon platform.""" if discovery_info is not None: + webfsapi_url = await AFSAPI.get_webfsapi_endpoint( + discovery_info["ssdp_description"] + ) + afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN) + + name = await afsapi.get_friendly_name() async_add_entities( - [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD, None)], + [AFSAPIDevice(name, afsapi)], True, ) return @@ -64,19 +69,23 @@ async def async_setup_platform( name = config.get(CONF_NAME) try: - async_add_entities( - [AFSAPIDevice(f"http://{host}:{port}/device", password, name)], True + webfsapi_url = await AFSAPI.get_webfsapi_endpoint( + f"http://{host}:{port}/device" ) - _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) - except requests.exceptions.RequestException: + except FSConnectionError: _LOGGER.error( "Could not add the FSAPI device at %s:%s -> %s", host, port, password ) + return + afsapi = AFSAPI(webfsapi_url, password) + async_add_entities([AFSAPIDevice(name, afsapi)], True) class AFSAPIDevice(MediaPlayerEntity): """Representation of a Frontier Silicon device on the network.""" + _attr_media_content_type: str = MEDIA_TYPE_MUSIC + _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.VOLUME_SET @@ -91,142 +100,107 @@ class AFSAPIDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - def __init__(self, device_url, password, name): + def __init__(self, name: str | None, afsapi: AFSAPI) -> None: """Initialize the Frontier Silicon API device.""" - self._device_url = device_url - self._password = password - self._state = None + self.fs_device = afsapi + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, afsapi.webfsapi_endpoint)}, + name=name, + ) + self._attr_name = name - self._name = name - self._title = None - self._artist = None - self._album_name = None - self._mute = None - self._source = None - self._source_list = None - self._media_image_url = None self._max_volume = None - self._volume_level = None - # Properties - @property - def fs_device(self): - """ - Create a fresh fsapi session. - - A new session is created for each request in case someone else - connected to the device in between the updates and invalidated the - existing session (i.e UNDOK). - """ - return AFSAPI(self._device_url, self._password) - - @property - def name(self): - """Return the device name.""" - return self._name - - @property - def media_title(self): - """Title of current playing media.""" - return self._title - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - return self._artist - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self._album_name - - @property - def media_content_type(self): - """Content type of current playing media.""" - return MEDIA_TYPE_MUSIC - - @property - def state(self): - """Return the state of the player.""" - return self._state - - # source - @property - def source_list(self): - """List of available input sources.""" - return self._source_list - - @property - def source(self): - """Name of the current input source.""" - return self._source - - @property - def media_image_url(self): - """Image url of current playing media.""" - return self._media_image_url - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume_level + self.__modes_by_label = None + self.__sound_modes_by_label = None async def async_update(self): """Get the latest date and update device state.""" - fs_device = self.fs_device + afsapi = self.fs_device + try: + if await afsapi.get_power(): + status = await afsapi.get_play_status() + self._attr_state = { + PlayState.PLAYING: STATE_PLAYING, + PlayState.PAUSED: STATE_PAUSED, + PlayState.STOPPED: STATE_IDLE, + PlayState.LOADING: STATE_OPENING, + None: STATE_IDLE, + }.get(status) + else: + self._attr_state = STATE_OFF + except FSConnectionError: + if self._attr_available: + _LOGGER.warning( + "Could not connect to %s. Did it go offline?", + self.name or afsapi.webfsapi_endpoint, + ) + self._attr_available = False + return - if not self._name: - self._name = await fs_device.get_friendly_name() + if not self._attr_available: + _LOGGER.info( + "Reconnected to %s", + self.name or afsapi.webfsapi_endpoint, + ) - if not self._source_list: - self._source_list = await fs_device.get_mode_list() + self._attr_available = True + if not self._attr_name: + self._attr_name = await afsapi.get_friendly_name() + + if not self._attr_source_list: + self.__modes_by_label = { + mode.label: mode.key for mode in await afsapi.get_modes() + } + self._attr_source_list = list(self.__modes_by_label) + + if not self._attr_sound_mode_list: + self.__sound_modes_by_label = { + sound_mode.label: sound_mode.key + for sound_mode in await afsapi.get_equalisers() + } + self._attr_sound_mode_list = list(self.__sound_modes_by_label) # The API seems to include 'zero' in the number of steps (e.g. if the range is # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. # If call to get_volume fails set to 0 and try again next time. if not self._max_volume: - self._max_volume = int(await fs_device.get_volume_steps() or 1) - 1 + self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 - if await fs_device.get_power(): - status = await fs_device.get_play_status() - self._state = { - "playing": STATE_PLAYING, - "paused": STATE_PAUSED, - "stopped": STATE_IDLE, - "unknown": STATE_UNKNOWN, - None: STATE_IDLE, - }.get(status, STATE_UNKNOWN) - else: - self._state = STATE_OFF + if self._attr_state != STATE_OFF: + info_name = await afsapi.get_play_name() + info_text = await afsapi.get_play_text() - if self._state != STATE_OFF: - info_name = await fs_device.get_play_name() - info_text = await fs_device.get_play_text() + self._attr_media_title = " - ".join(filter(None, [info_name, info_text])) + self._attr_media_artist = await afsapi.get_play_artist() + self._attr_media_album_name = await afsapi.get_play_album() - self._title = " - ".join(filter(None, [info_name, info_text])) - self._artist = await fs_device.get_play_artist() - self._album_name = await fs_device.get_play_album() + self._attr_source = (await afsapi.get_mode()).label - self._source = await fs_device.get_mode() - self._mute = await fs_device.get_mute() - self._media_image_url = await fs_device.get_play_graphic() + self._attr_is_volume_muted = await afsapi.get_mute() + self._attr_media_image_url = await afsapi.get_play_graphic() + self._attr_sound_mode = (await afsapi.get_eq_preset()).label volume = await self.fs_device.get_volume() # Prevent division by zero if max_volume not known yet - self._volume_level = float(volume or 0) / (self._max_volume or 1) + self._attr_volume_level = float(volume or 0) / (self._max_volume or 1) else: - self._title = None - self._artist = None - self._album_name = None + self._attr_media_title = None + self._attr_media_artist = None + self._attr_media_album_name = None - self._source = None - self._mute = None - self._media_image_url = None + self._attr_source = None - self._volume_level = None + self._attr_is_volume_muted = None + self._attr_media_image_url = None + self._attr_sound_mode = None + + self._attr_volume_level = None # Management actions # power control @@ -248,7 +222,7 @@ class AFSAPIDevice(MediaPlayerEntity): async def async_media_play_pause(self): """Send play/pause command.""" - if "playing" in self._state: + if self._attr_state == STATE_PLAYING: await self.fs_device.pause() else: await self.fs_device.play() @@ -265,12 +239,6 @@ class AFSAPIDevice(MediaPlayerEntity): """Send next track command (results in fast-forward).""" await self.fs_device.forward() - # mute - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._mute - async def async_mute_volume(self, mute): """Send mute command.""" await self.fs_device.set_mute(mute) @@ -296,4 +264,9 @@ class AFSAPIDevice(MediaPlayerEntity): async def async_select_source(self, source): """Select input source.""" - await self.fs_device.set_mode(source) + await self.fs_device.set_power(True) + await self.fs_device.set_mode(self.__modes_by_label.get(source)) + + async def async_select_sound_mode(self, sound_mode): + """Select EQ Preset.""" + await self.fs_device.set_eq_preset(self.__sound_modes_by_label[sound_mode]) diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 75198fa2f60..826f21e9f88 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -2,11 +2,16 @@ from __future__ import annotations import logging +from typing import Any import requests import voluptuous as vol -from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity +from homeassistant.components.cover import ( + PLATFORM_SCHEMA, + CoverDeviceClass, + CoverEntity, +) from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_COVERS, @@ -134,17 +139,17 @@ class GaradgetCover(CoverEntity): self.remove_token() @property - def name(self): + def name(self) -> str: """Return the name of the cover.""" return self._name @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" data = {} @@ -163,16 +168,16 @@ class GaradgetCover(CoverEntity): return data @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._state is None: return None return self._state == STATE_CLOSED @property - def device_class(self): + def device_class(self) -> CoverDeviceClass: """Return the class of this device, from component DEVICE_CLASSES.""" - return "garage" + return CoverDeviceClass.GARAGE def get_token(self): """Get new token for usage during this session.""" @@ -207,28 +212,28 @@ class GaradgetCover(CoverEntity): """Check the state of the service during an operation.""" self.schedule_update_ha_state(True) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" if self._state not in ["close", "closing"]: ret = self._put_command("setState", "close") self._start_watcher("close") return ret.get("return_value") == 1 - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" if self._state not in ["open", "opening"]: ret = self._put_command("setState", "open") self._start_watcher("open") return ret.get("return_value") == 1 - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the door where it is.""" if self._state not in ["stopped"]: ret = self._put_command("setState", "stop") self._start_watcher("stop") return ret["return_value"] == 1 - def update(self): + def update(self) -> None: """Get updated status from API.""" try: status = self._get_variable("doorStatus") diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index edc51430f0d..961d3cecfb7 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,10 +1,13 @@ """Support for IP Cameras.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any import httpx import voluptuous as vol +import yarl from homeassistant.components.camera import ( DEFAULT_CONTENT_TYPE, @@ -115,12 +118,12 @@ async def async_setup_entry( ) -def generate_auth(device_info) -> httpx.Auth | None: +def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None: """Generate httpx.Auth object from credentials.""" - username = device_info.get(CONF_USERNAME) - password = device_info.get(CONF_PASSWORD) + username: str | None = device_info.get(CONF_USERNAME) + password: str | None = device_info.get(CONF_PASSWORD) authentication = device_info.get(CONF_AUTHENTICATION) - if username: + if username and password: if authentication == HTTP_DIGEST_AUTHENTICATION: return httpx.DigestAuth(username=username, password=password) return httpx.BasicAuth(username=username, password=password) @@ -130,12 +133,22 @@ def generate_auth(device_info) -> httpx.Auth | None: class GenericCamera(Camera): """A generic implementation of an IP camera.""" - def __init__(self, hass, device_info, identifier, title): + _last_image: bytes | None + + def __init__( + self, + hass: HomeAssistant, + device_info: Mapping[str, Any], + identifier: str, + title: str, + ) -> None: """Initialize a generic camera.""" super().__init__() self.hass = hass self._attr_unique_id = identifier self._authentication = device_info.get(CONF_AUTHENTICATION) + self._username = device_info.get(CONF_USERNAME) + self._password = device_info.get(CONF_PASSWORD) self._name = device_info.get(CONF_NAME, title) self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL) if ( @@ -143,10 +156,10 @@ class GenericCamera(Camera): and self._still_image_url ): self._still_image_url = cv.template(self._still_image_url) - if self._still_image_url not in [None, ""]: + if self._still_image_url: self._still_image_url.hass = hass self._stream_source = device_info.get(CONF_STREAM_SOURCE) - if self._stream_source not in (None, ""): + if self._stream_source: if not isinstance(self._stream_source, template_helper.Template): self._stream_source = cv.template(self._stream_source) self._stream_source.hass = hass @@ -207,13 +220,23 @@ class GenericCamera(Camera): """Return the name of this device.""" return self._name - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the source of the stream.""" if self._stream_source is None: return None try: - return self._stream_source.async_render(parse_result=False) + stream_url = self._stream_source.async_render(parse_result=False) + url = yarl.URL(stream_url) + if ( + not url.user + and not url.password + and self._username + and self._password + and url.is_absolute() + ): + url = url.with_user(self._username).with_password(self._password) + return str(url) except TemplateError as err: _LOGGER.error("Error parsing template %s: %s", self._stream_source, err) return None diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 272c7a2d98e..514264f919e 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -1,17 +1,15 @@ """Config flow for generic (IP Camera).""" from __future__ import annotations +from collections.abc import Mapping import contextlib from errno import EHOSTUNREACH, EIO -from functools import partial import io import logging -from types import MappingProxyType from typing import Any import PIL from async_timeout import timeout -import av from httpx import HTTPStatusError, RequestError, TimeoutException import voluptuous as vol import yarl @@ -19,9 +17,10 @@ import yarl from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, + HLS_PROVIDER, RTSP_TRANSPORTS, SOURCE_TIMEOUT, - convert_stream_options, + create_stream, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( @@ -33,6 +32,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper @@ -65,7 +65,7 @@ SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} def build_schema( - user_input: dict[str, Any] | MappingProxyType[str, Any], + user_input: Mapping[str, Any], is_options_flow: bool = False, show_advanced_options=False, ): @@ -120,7 +120,7 @@ def build_schema( return vol.Schema(spec) -def get_image_type(image): +def get_image_type(image: bytes) -> str | None: """Get the format of downloaded bytes that could be an image.""" fmt = None imagefile = io.BytesIO(image) @@ -136,7 +136,9 @@ def get_image_type(image): return fmt -async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]: +async def async_test_still( + hass: HomeAssistant, info: Mapping[str, Any] +) -> tuple[dict[str, str], str | None]: """Verify that the still image is valid before we create an entity.""" fmt = None if not (url := info.get(CONF_STILL_IMAGE_URL)): @@ -148,7 +150,13 @@ async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]: except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", url, err) return {CONF_STILL_IMAGE_URL: "template_error"}, None - verify_ssl = info.get(CONF_VERIFY_SSL) + try: + yarl_url = yarl.URL(url) + except ValueError: + return {CONF_STILL_IMAGE_URL: "malformed_url"}, None + if not yarl_url.is_absolute(): + return {CONF_STILL_IMAGE_URL: "relative_url"}, None + verify_ssl = info[CONF_VERIFY_SSL] auth = generate_auth(info) try: async_client = get_async_client(hass, verify_ssl=verify_ssl) @@ -178,7 +186,9 @@ async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]: return {}, f"image/{fmt}" -def slug(hass, template) -> str | None: +def slug( + hass: HomeAssistant, template: str | template_helper.Template | None +) -> str | None: """Convert a camera url into a string suitable for a camera name.""" if not template: return None @@ -194,10 +204,17 @@ def slug(hass, template) -> str | None: return None -async def async_test_stream(hass, info) -> dict[str, str]: +async def async_test_stream( + hass: HomeAssistant, info: Mapping[str, Any] +) -> dict[str, str]: """Verify that the stream is valid before we create an entity.""" if not (stream_source := info.get(CONF_STREAM_SOURCE)): return {} + # Import from stream.worker as stream cannot reexport from worker + # without forcing the av dependency on default_config + # pylint: disable=import-outside-toplevel + from homeassistant.components.stream.worker import StreamWorkerError + if not isinstance(stream_source, template_helper.Template): stream_source = template_helper.Template(stream_source, hass) try: @@ -205,42 +222,34 @@ async def async_test_stream(hass, info) -> dict[str, str]: except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", stream_source, err) return {CONF_STREAM_SOURCE: "template_error"} + stream_options: dict[str, str | bool | float] = {} + if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): + stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport + if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True + try: - # For RTSP streams, prefer TCP. This code is duplicated from - # homeassistant.components.stream.__init__.py:create_stream() - # It may be possible & better to call create_stream() directly. - stream_options: dict[str, bool | str] = {} - if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): - stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport - if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): - stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True - pyav_options = convert_stream_options(stream_options) - if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": - pyav_options = { - "rtsp_flags": "prefer_tcp", - "stimeout": "5000000", - **pyav_options, - } - _LOGGER.debug("Attempting to open stream %s", stream_source) - container = await hass.async_add_executor_job( - partial( - av.open, - stream_source, - options=pyav_options, - timeout=SOURCE_TIMEOUT, - ) - ) - _ = container.streams.video[0] - except (av.error.FileNotFoundError): # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "stream_file_not_found"} - except (av.error.HTTPNotFoundError): # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "stream_http_not_found"} - except (av.error.TimeoutError): # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "timeout"} - except av.error.HTTPUnauthorizedError: # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "stream_unauthorised"} - except (KeyError, IndexError): - return {CONF_STREAM_SOURCE: "stream_no_video"} + url = yarl.URL(stream_source) + except ValueError: + return {CONF_STREAM_SOURCE: "malformed_url"} + if not url.is_absolute(): + return {CONF_STREAM_SOURCE: "relative_url"} + if not url.user and not url.password: + username = info.get(CONF_USERNAME) + password = info.get(CONF_PASSWORD) + if username and password: + url = url.with_user(username).with_password(password) + stream_source = str(url) + try: + stream = create_stream(hass, stream_source, stream_options, "test_stream") + hls_provider = stream.add_provider(HLS_PROVIDER) + await stream.start() + if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT): + hass.async_create_task(stream.stop()) + return {CONF_STREAM_SOURCE: "timeout"} + await stream.stop() + except StreamWorkerError as err: + return {CONF_STREAM_SOURCE: str(err)} except PermissionError: return {CONF_STREAM_SOURCE: "stream_not_permitted"} except OSError as err: @@ -257,7 +266,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize Generic ConfigFlow.""" self.cached_user_input: dict[str, Any] = {} self.cached_title = "" @@ -269,7 +278,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return GenericOptionsFlowHandler(config_entry) - def check_for_existing(self, options): + def check_for_existing(self, options: dict[str, Any]) -> bool: """Check whether an existing entry is using the same URLs.""" return any( entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL) @@ -290,14 +299,16 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): ): errors["base"] = "no_still_image_or_stream_url" else: - errors, still_format = await async_test_still(self.hass, user_input) - errors = errors | await async_test_stream(self.hass, user_input) - still_url = user_input.get(CONF_STILL_IMAGE_URL) - stream_url = user_input.get(CONF_STREAM_SOURCE) - name = slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME + errors, still_format = await async_test_still(hass, user_input) + errors = errors | await async_test_stream(hass, user_input) if not errors: user_input[CONF_CONTENT_TYPE] = still_format user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False + still_url = user_input.get(CONF_STILL_IMAGE_URL) + stream_url = user_input.get(CONF_STREAM_SOURCE) + name = ( + slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME + ) if still_url is None: # If user didn't specify a still image URL, # The automatically generated still image that stream generates @@ -316,7 +327,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config) -> FlowResult: + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Handle config import from yaml.""" # abort if we've already got this one. if self.check_for_existing(import_config): @@ -328,6 +339,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): CONF_NAME, slug(self.hass, still_url) or slug(self.hass, stream_url) or DEFAULT_NAME, ) + if CONF_LIMIT_REFETCH_TO_URL_CHANGE not in import_config: import_config[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False still_format = import_config.get(CONF_CONTENT_TYPE, "image/jpeg") @@ -353,9 +365,9 @@ class GenericOptionsFlowHandler(OptionsFlow): if user_input is not None: errors, still_format = await async_test_still( - self.hass, self.config_entry.options | user_input + hass, self.config_entry.options | user_input ) - errors = errors | await async_test_stream(self.hass, user_input) + errors = errors | await async_test_stream(hass, user_input) still_url = user_input.get(CONF_STILL_IMAGE_URL) stream_url = user_input.get(CONF_STREAM_SOURCE) if not errors: diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index c590ddfffcd..5ef47f0c941 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -2,7 +2,7 @@ "domain": "generic", "name": "Generic Camera", "config_flow": true, - "requirements": ["av==9.2.0", "pillow==9.1.1"], + "requirements": ["ha-av==10.0.0b4", "pillow==9.1.1"], "documentation": "https://www.home-assistant.io/integrations/generic", "codeowners": ["@davet2001"], "iot_class": "local_push" diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 6b73c70cf3d..608c85c1379 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -6,19 +6,18 @@ "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", + "relative_url": "Relative URLs are not allowed", "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "stream_no_route_to_host": "Could not find host while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", - "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_no_video": "Stream has no video" + "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { "user": { @@ -78,15 +77,13 @@ "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "no_still_image_or_stream_url": "[%key:component::generic::config::error::no_still_image_or_stream_url%]", "invalid_still_image": "[%key:component::generic::config::error::invalid_still_image%]", - "stream_file_not_found": "[%key:component::generic::config::error::stream_file_not_found%]", - "stream_http_not_found": "[%key:component::generic::config::error::stream_http_not_found%]", + "malformed_url": "[%key:component::generic::config::error::malformed_url%]", + "relative_url": "[%key:component::generic::config::error::relative_url%]", "template_error": "[%key:component::generic::config::error::template_error%]", "timeout": "[%key:component::generic::config::error::timeout%]", "stream_no_route_to_host": "[%key:component::generic::config::error::stream_no_route_to_host%]", "stream_io_error": "[%key:component::generic::config::error::stream_io_error%]", - "stream_unauthorised": "[%key:component::generic::config::error::stream_unauthorised%]", - "stream_not_permitted": "[%key:component::generic::config::error::stream_not_permitted%]", - "stream_no_video": "[%key:component::generic::config::error::stream_no_video%]" + "stream_not_permitted": "[%key:component::generic::config::error::stream_not_permitted%]" } } } diff --git a/homeassistant/components/generic/translations/ca.json b/homeassistant/components/generic/translations/ca.json index 3f2cd6afa47..818a030c8a7 100644 --- a/homeassistant/components/generic/translations/ca.json +++ b/homeassistant/components/generic/translations/ca.json @@ -15,6 +15,7 @@ "stream_no_video": "El flux no cont\u00e9 v\u00eddeo", "stream_not_permitted": "Operaci\u00f3 no permesa mentre s'intentava connectar al flux de dades. Protocol de transport RTSP incorrecte?", "stream_unauthorised": "L'autoritzaci\u00f3 ha fallat mentre s'intentava connectar amb el flux de dades", + "template_error": "Error renderitzant plantilla. Consulta els registres per m\u00e9s informaci\u00f3.", "timeout": "El temps m\u00e0xim de c\u00e0rrega de l'URL ha expirat", "unable_still_load": "No s'ha pogut carregar cap imatge v\u00e0lida des de l'URL d'imatge fixa (pot ser per un amfitri\u00f3 o URL inv\u00e0lid o un error d'autenticaci\u00f3). Revisa els registres per a m\u00e9s informaci\u00f3.", "unknown": "Error inesperat" @@ -57,6 +58,7 @@ "stream_no_video": "El flux no cont\u00e9 v\u00eddeo", "stream_not_permitted": "Operaci\u00f3 no permesa mentre s'intentava connectar al flux de dades. Protocol de transport RTSP incorrecte?", "stream_unauthorised": "L'autoritzaci\u00f3 ha fallat mentre s'intentava connectar amb el flux de dades", + "template_error": "Error renderitzant plantilla. Consulta els registres per m\u00e9s informaci\u00f3.", "timeout": "El temps m\u00e0xim de c\u00e0rrega de l'URL ha expirat", "unable_still_load": "No s'ha pogut carregar cap imatge v\u00e0lida des de l'URL d'imatge fixa (pot ser per un amfitri\u00f3 o URL inv\u00e0lid o un error d'autenticaci\u00f3). Revisa els registres per a m\u00e9s informaci\u00f3.", "unknown": "Error inesperat" diff --git a/homeassistant/components/generic/translations/de.json b/homeassistant/components/generic/translations/de.json index d4c5c268eed..0c14e95a683 100644 --- a/homeassistant/components/generic/translations/de.json +++ b/homeassistant/components/generic/translations/de.json @@ -15,6 +15,7 @@ "stream_no_video": "Stream enth\u00e4lt kein Video", "stream_not_permitted": "Beim Versuch, eine Verbindung zum Stream herzustellen, ist ein Vorgang nicht zul\u00e4ssig. Falsches RTSP-Transportprotokoll?", "stream_unauthorised": "Autorisierung beim Versuch, eine Verbindung zum Stream herzustellen, fehlgeschlagen", + "template_error": "Fehler beim Rendern der Vorlage. \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "timeout": "Zeit\u00fcberschreitung beim Laden der URL", "unable_still_load": "Es konnte kein g\u00fcltiges Bild von der Standbild-URL geladen werden (z. B. ung\u00fcltiger Host, URL oder Authentifizierungsfehler). \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "unknown": "Unerwarteter Fehler" @@ -57,6 +58,7 @@ "stream_no_video": "Stream enth\u00e4lt kein Video", "stream_not_permitted": "Beim Versuch, eine Verbindung zum Stream herzustellen, ist ein Vorgang nicht zul\u00e4ssig. Falsches RTSP-Transportprotokoll?", "stream_unauthorised": "Autorisierung beim Versuch, eine Verbindung zum Stream herzustellen, fehlgeschlagen", + "template_error": "Fehler beim Rendern der Vorlage. \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "timeout": "Zeit\u00fcberschreitung beim Laden der URL", "unable_still_load": "Es konnte kein g\u00fcltiges Bild von der Standbild-URL geladen werden (z. B. ung\u00fcltiger Host, URL oder Authentifizierungsfehler). \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "unknown": "Unerwarteter Fehler" diff --git a/homeassistant/components/generic/translations/el.json b/homeassistant/components/generic/translations/el.json index be1c963740a..f97714a53c1 100644 --- a/homeassistant/components/generic/translations/el.json +++ b/homeassistant/components/generic/translations/el.json @@ -15,6 +15,7 @@ "stream_no_video": "\u0397 \u03c1\u03bf\u03ae \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03b2\u03af\u03bd\u03c4\u03b5\u03bf", "stream_not_permitted": "\u0394\u03b5\u03bd \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03c4\u03b1\u03b9 \u03b7 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03b5 \u03c1\u03bf\u03ae. \u039b\u03ac\u03b8\u03bf\u03c2 \u03c0\u03c1\u03c9\u03c4\u03cc\u03ba\u03bf\u03bb\u03bb\u03bf \u03bc\u03b5\u03c4\u03b1\u03c6\u03bf\u03c1\u03ac\u03c2 RTSP;", "stream_unauthorised": "\u0397 \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7 \u03b1\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03c4\u03b7 \u03c1\u03bf\u03ae", + "template_error": "\u03a3\u03c6\u03ac\u03bb\u03bc\u03b1 \u03b1\u03c0\u03cc\u03b4\u03bf\u03c3\u03b7\u03c2 \u03c0\u03c1\u03bf\u03c4\u03cd\u03c0\u03bf\u03c5. \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL", "unable_still_load": "\u0391\u03b4\u03c5\u03bd\u03b1\u03bc\u03af\u03b1 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7\u03c2 \u03ad\u03b3\u03ba\u03c5\u03c1\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 \u03b1\u03c0\u03cc \u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03b1\u03ba\u03af\u03bd\u03b7\u03c4\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 (\u03c0.\u03c7. \u03bc\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ba\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2, \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03ae \u03b1\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2). \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" @@ -57,6 +58,7 @@ "stream_no_video": "\u0397 \u03c1\u03bf\u03ae \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03b2\u03af\u03bd\u03c4\u03b5\u03bf", "stream_not_permitted": "\u0394\u03b5\u03bd \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03c4\u03b1\u03b9 \u03b7 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03b5 \u03c1\u03bf\u03ae. \u039b\u03ac\u03b8\u03bf\u03c2 \u03c0\u03c1\u03c9\u03c4\u03cc\u03ba\u03bf\u03bb\u03bb\u03bf \u03bc\u03b5\u03c4\u03b1\u03c6\u03bf\u03c1\u03ac\u03c2 RTSP;", "stream_unauthorised": "\u0397 \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7 \u03b1\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03c4\u03b7 \u03c1\u03bf\u03ae", + "template_error": "\u03a3\u03c6\u03ac\u03bb\u03bc\u03b1 \u03b1\u03c0\u03cc\u03b4\u03bf\u03c3\u03b7\u03c2 \u03c0\u03c1\u03bf\u03c4\u03cd\u03c0\u03bf\u03c5. \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL", "unable_still_load": "\u0391\u03b4\u03c5\u03bd\u03b1\u03bc\u03af\u03b1 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7\u03c2 \u03ad\u03b3\u03ba\u03c5\u03c1\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 \u03b1\u03c0\u03cc \u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03b1\u03ba\u03af\u03bd\u03b7\u03c4\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 (\u03c0.\u03c7. \u03bc\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ba\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2, \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03ae \u03b1\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2). \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index d01e6e59a4b..cb2200f9755 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -1,20 +1,19 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", + "relative_url": "Relative URLs are not allowed", "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", @@ -50,14 +49,12 @@ "error": { "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", - "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", - "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", + "relative_url": "Relative URLs are not allowed", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", diff --git a/homeassistant/components/generic/translations/es.json b/homeassistant/components/generic/translations/es.json index 4d8019002bb..8e1189f30f1 100644 --- a/homeassistant/components/generic/translations/es.json +++ b/homeassistant/components/generic/translations/es.json @@ -1,20 +1,29 @@ { "config": { "abort": { + "no_devices_found": "No se encontraron dispositivos en la red", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { "already_exists": "Ya hay una c\u00e1mara con esa URL de configuraci\u00f3n.", "invalid_still_image": "La URL no ha devuelto una imagen fija v\u00e1lida", "no_still_image_or_stream_url": "Tienes que especificar al menos una imagen una URL de flujo", + "stream_file_not_found": "No se encontr\u00f3 el archivo al intentar conectarse a la transmisi\u00f3n (\u00bfest\u00e1 instalado ffmpeg?)", "stream_http_not_found": "HTTP 404 'Not found' al intentar conectarse al flujo de datos ('stream')", + "stream_io_error": "Error de entrada/salida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_no_route_to_host": "No se pudo encontrar el anfitri\u00f3n mientras intentaba conectar al flujo de datos", "stream_no_video": "El flujo no contiene v\u00eddeo", + "stream_not_permitted": "Operaci\u00f3n no permitida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_unauthorised": "La autorizaci\u00f3n ha fallado mientras se intentaba conectar con el flujo de datos", + "template_error": "Error al renderizar la plantilla. Revise el registro para m\u00e1s informaci\u00f3n.", "timeout": "El tiempo m\u00e1ximo de carga de la URL ha expirado", + "unable_still_load": "No se puede cargar una imagen v\u00e1lida desde la URL de la imagen fija (p. ej., host no v\u00e1lido, URL o error de autenticaci\u00f3n). Revise el registro para obtener m\u00e1s informaci\u00f3n.", "unknown": "Error inesperado" }, "step": { + "confirm": { + "description": "\u00bfQuiere empezar a configurar?" + }, "content_type": { "data": { "content_type": "Tipos de contenido" @@ -26,20 +35,32 @@ "authentication": "Autenticaci\u00f3n", "framerate": "Frecuencia de visualizaci\u00f3n (Hz)", "limit_refetch_to_url_change": "Limita la lectura al cambio de URL", + "password": "Contrase\u00f1a", + "rtsp_transport": "Protocolo de transporte RTSP", "still_image_url": "URL de imagen fija (ej. http://...)", "stream_source": "URL origen del flux (p. ex. rtsp://...)", "username": "Usuario", "verify_ssl": "Verifica el certificat SSL" - } + }, + "description": "Introduzca los ajustes para conectarse a la c\u00e1mara." } } }, "options": { "error": { "already_exists": "Ya hay una c\u00e1mara con esa URL de configuraci\u00f3n.", + "invalid_still_image": "La URL no devolvi\u00f3 una imagen fija v\u00e1lida", + "no_still_image_or_stream_url": "Debe especificar al menos una imagen fija o URL de transmisi\u00f3n", + "stream_file_not_found": "No se encontr\u00f3 el archivo al intentar conectarse a la transmisi\u00f3n (\u00bfest\u00e1 instalado ffmpeg?)", + "stream_http_not_found": "HTTP 404 No encontrado al intentar conectarse a la transmisi\u00f3n", + "stream_io_error": "Error de entrada/salida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_no_route_to_host": "No se pudo encontrar el anfitri\u00f3n mientras intentaba conectar al flujo de datos", + "stream_no_video": "La transmisi\u00f3n no tiene video", + "stream_not_permitted": "Operaci\u00f3n no permitida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_unauthorised": "La autorizaci\u00f3n ha fallado mientras se intentaba conectar con el flujo de datos", + "template_error": "Error al renderizar la plantilla. Revise el registro para m\u00e1s informaci\u00f3n.", "timeout": "El tiempo m\u00e1ximo de carga de la URL ha expirado", + "unable_still_load": "No se puede cargar una imagen v\u00e1lida desde la URL de la imagen fija (por ejemplo, host no v\u00e1lido, URL o error de autenticaci\u00f3n). Revise el registro para obtener m\u00e1s informaci\u00f3n.", "unknown": "Error inesperado" }, "step": { @@ -57,8 +78,13 @@ "password": "Contrase\u00f1a", "rtsp_transport": "Protocolo de transporte RTSP", "still_image_url": "URL de imagen fija (ej. http://...)", + "stream_source": "URL de origen de la transmisi\u00f3n (por ejemplo, rtsp://...)", + "use_wallclock_as_timestamps": "Usar el reloj de pared como marca de tiempo", "username": "Usuario", "verify_ssl": "Verifica el certificado SSL" + }, + "data_description": { + "use_wallclock_as_timestamps": "Esta opci\u00f3n puede corregir los problemas de segmentaci\u00f3n o bloqueo que surgen de las implementaciones de marcas de tiempo defectuosas en algunas c\u00e1maras" } } } diff --git a/homeassistant/components/generic/translations/et.json b/homeassistant/components/generic/translations/et.json index 41e1881573e..a50e4d4aaa7 100644 --- a/homeassistant/components/generic/translations/et.json +++ b/homeassistant/components/generic/translations/et.json @@ -15,6 +15,7 @@ "stream_no_video": "Voos pole videot", "stream_not_permitted": "Vooga \u00fchenduse loomisel pole toiming lubatud. Vale RTSP transpordiprotokoll?", "stream_unauthorised": "Autoriseerimine eba\u00f5nnestus vooga \u00fchendamise ajal", + "template_error": "Viga malli renderdamisel. Lisateabe saamiseks vaata logi.", "timeout": "URL-i laadimise ajal\u00f5pp", "unable_still_load": "Pilti ei saa laadida URL-ist (nt kehtetu host, URL v\u00f5i autentimise t\u00f5rge). Lisateabe saamiseks vaata logi.", "unknown": "Ootamatu t\u00f5rge" @@ -57,6 +58,7 @@ "stream_no_video": "Voos pole videot", "stream_not_permitted": "Vooga \u00fchenduse loomisel pole toiming lubatud. Vale RTSP transpordiprotokoll?", "stream_unauthorised": "Autoriseerimine eba\u00f5nnestus vooga \u00fchendamise ajal", + "template_error": "Viga malli renderdamisel. Lisateabe saamiseks vaata logi.", "timeout": "URL-i laadimise ajal\u00f5pp", "unable_still_load": "Pilti ei saa laadida URL-ist (nt kehtetu host, URL v\u00f5i autentimise t\u00f5rge). Lisateabe saamiseks vaata logi.", "unknown": "Ootamatu t\u00f5rge" diff --git a/homeassistant/components/generic/translations/fr.json b/homeassistant/components/generic/translations/fr.json index 6215c579be7..0d517a846e7 100644 --- a/homeassistant/components/generic/translations/fr.json +++ b/homeassistant/components/generic/translations/fr.json @@ -15,6 +15,7 @@ "stream_no_video": "Le flux ne contient pas de vid\u00e9o", "stream_not_permitted": "Op\u00e9ration non autoris\u00e9e lors de la tentative de connexion au flux. Mauvais protocole de transport RTSP\u00a0?", "stream_unauthorised": "\u00c9chec de l'autorisation lors de la tentative de connexion au flux", + "template_error": "Erreur lors du rendu du mod\u00e8le. Consultez le journal pour plus d'informations.", "timeout": "D\u00e9lai d'attente expir\u00e9 lors du chargement de l'URL", "unable_still_load": "Impossible de charger une image valide depuis l'URL d'image fixe (cela pourrait \u00eatre d\u00fb \u00e0 un h\u00f4te ou \u00e0 une URL non valide, ou \u00e0 un \u00e9chec de l'authentification). Consultez le journal pour plus d'informations.", "unknown": "Erreur inattendue" @@ -57,6 +58,7 @@ "stream_no_video": "Le flux ne contient pas de vid\u00e9o", "stream_not_permitted": "Op\u00e9ration non autoris\u00e9e lors de la tentative de connexion au flux. Mauvais protocole de transport RTSP\u00a0?", "stream_unauthorised": "\u00c9chec de l'autorisation lors de la tentative de connexion au flux", + "template_error": "Erreur lors du rendu du mod\u00e8le. Consultez le journal pour plus d'informations.", "timeout": "D\u00e9lai d'attente expir\u00e9 lors du chargement de l'URL", "unable_still_load": "Impossible de charger une image valide depuis l'URL d'image fixe (cela pourrait \u00eatre d\u00fb \u00e0 un h\u00f4te ou \u00e0 une URL non valide, ou \u00e0 un \u00e9chec de l'authentification). Consultez le journal pour plus d'informations.", "unknown": "Erreur inattendue" diff --git a/homeassistant/components/generic/translations/he.json b/homeassistant/components/generic/translations/he.json index f39f78074f5..3d2f8cdca4e 100644 --- a/homeassistant/components/generic/translations/he.json +++ b/homeassistant/components/generic/translations/he.json @@ -36,7 +36,7 @@ "limit_refetch_to_url_change": "\u05d4\u05d2\u05d1\u05dc\u05d4 \u05e9\u05dc \u05d0\u05d7\u05e1\u05d5\u05df \u05d7\u05d5\u05d6\u05e8 \u05dc\u05e9\u05d9\u05e0\u05d5\u05d9 \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05ea\u05e8", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "rtsp_transport": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc \u05ea\u05e2\u05d1\u05d5\u05e8\u05d4 RTSP", - "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, http://...)", + "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, https://...)", "stream_source": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05de\u05e7\u05d5\u05e8 \u05d6\u05e8\u05dd (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, rtsp://...)", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" @@ -75,7 +75,7 @@ "limit_refetch_to_url_change": "\u05d4\u05d2\u05d1\u05dc\u05d4 \u05e9\u05dc \u05d0\u05d7\u05e1\u05d5\u05df \u05d7\u05d5\u05d6\u05e8 \u05dc\u05e9\u05d9\u05e0\u05d5\u05d9 \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05ea\u05e8", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "rtsp_transport": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc \u05ea\u05e2\u05d1\u05d5\u05e8\u05d4 RTSP", - "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, http://...)", + "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, https://...)", "stream_source": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05de\u05e7\u05d5\u05e8 \u05d6\u05e8\u05dd (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, rtsp://...)", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" diff --git a/homeassistant/components/generic/translations/hu.json b/homeassistant/components/generic/translations/hu.json index 59840b3195b..76992a1fde7 100644 --- a/homeassistant/components/generic/translations/hu.json +++ b/homeassistant/components/generic/translations/hu.json @@ -15,6 +15,7 @@ "stream_no_video": "Az adatfolyamban nincs vide\u00f3", "stream_not_permitted": "A m\u0171velet nem enged\u00e9lyezett, mik\u00f6zben megpr\u00f3b\u00e1l csatlakozni a folyamhoz. Rossz fajta RTSP protokoll?", "stream_unauthorised": "A hiteles\u00edt\u00e9s meghi\u00fasult, mik\u00f6zben megpr\u00f3b\u00e1lt csatlakozni az adatfolyamhoz", + "template_error": "Hiba t\u00f6rt\u00e9nt a sablon renderel\u00e9se k\u00f6zben. Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az URL bet\u00f6lt\u00e9se k\u00f6zben", "unable_still_load": "Nem siker\u00fclt \u00e9rv\u00e9nyes k\u00e9pet bet\u00f6lteni az \u00e1ll\u00f3k\u00e9p URL-c\u00edm\u00e9r\u0151l (pl. \u00e9rv\u00e9nytelen host, URL vagy hiteles\u00edt\u00e9si hiba). Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" @@ -57,6 +58,7 @@ "stream_no_video": "Az adatfolyamban nincs vide\u00f3", "stream_not_permitted": "A m\u0171velet nem enged\u00e9lyezett, mik\u00f6zben megpr\u00f3b\u00e1l csatlakozni a folyamhoz. Rossz fajta RTSP protokoll?", "stream_unauthorised": "A hiteles\u00edt\u00e9s meghi\u00fasult, mik\u00f6zben megpr\u00f3b\u00e1lt csatlakozni az adatfolyamhoz", + "template_error": "Hiba t\u00f6rt\u00e9nt a sablon renderel\u00e9se k\u00f6zben. Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az URL bet\u00f6lt\u00e9se k\u00f6zben", "unable_still_load": "Nem siker\u00fclt \u00e9rv\u00e9nyes k\u00e9pet bet\u00f6lteni az \u00e1ll\u00f3k\u00e9p URL-c\u00edm\u00e9r\u0151l (pl. \u00e9rv\u00e9nytelen host, URL vagy hiteles\u00edt\u00e9si hiba). Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" diff --git a/homeassistant/components/generic/translations/id.json b/homeassistant/components/generic/translations/id.json index 95c6d6e2ae2..a9c553580ca 100644 --- a/homeassistant/components/generic/translations/id.json +++ b/homeassistant/components/generic/translations/id.json @@ -15,6 +15,7 @@ "stream_no_video": "Streaming tidak memiliki video", "stream_not_permitted": "Operasi tidak diizinkan saat mencoba menyambung ke streaming. Apakah protokol transportasi RTSP salah?", "stream_unauthorised": "Otorisasi gagal saat mencoba menyambung ke streaming", + "template_error": "Kesalahan saat merender templat. Tinjau log untuk info lebih lanjut.", "timeout": "Tenggang waktu habis saat memuat URL", "unable_still_load": "Tidak dapat memuat gambar yang valid dari URL gambar diam (mis. host yang tidak valid, URL, atau kegagalan autentikasi). Tinjau log untuk info lebih lanjut.", "unknown": "Kesalahan yang tidak diharapkan" @@ -57,6 +58,7 @@ "stream_no_video": "Streaming tidak memiliki video", "stream_not_permitted": "Operasi tidak diizinkan saat mencoba menyambung ke streaming. Apakah protokol transportasi RTSP salah?", "stream_unauthorised": "Otorisasi gagal saat mencoba menyambung ke streaming", + "template_error": "Kesalahan saat merender templat. Tinjau log untuk info lebih lanjut.", "timeout": "Tenggang waktu habis saat memuat URL", "unable_still_load": "Tidak dapat memuat gambar yang valid dari URL gambar diam (mis. host yang tidak valid, URL, atau kegagalan autentikasi). Tinjau log untuk info lebih lanjut.", "unknown": "Kesalahan yang tidak diharapkan" diff --git a/homeassistant/components/generic/translations/it.json b/homeassistant/components/generic/translations/it.json index ff4b9822601..1cd63544700 100644 --- a/homeassistant/components/generic/translations/it.json +++ b/homeassistant/components/generic/translations/it.json @@ -15,6 +15,7 @@ "stream_no_video": "Il flusso non ha video", "stream_not_permitted": "Operazione non consentita durante il tentativo di connessione al . Protocollo di trasporto RTSP errato?", "stream_unauthorised": "Autorizzazione non riuscita durante il tentativo di connessione al flusso", + "template_error": "Errore durante l'esecuzione del modello. Esamina il registro per ulteriori informazioni.", "timeout": "Timeout durante il caricamento dell'URL", "unable_still_load": "Impossibile caricare un'immagine valida dall'URL dell'immagine fissa (ad es. host, URL non valido o errore di autenticazione). Esamina il registro per ulteriori informazioni.", "unknown": "Errore imprevisto" @@ -57,6 +58,7 @@ "stream_no_video": "Il flusso non ha video", "stream_not_permitted": "Operazione non consentita durante il tentativo di connessione al . Protocollo di trasporto RTSP errato?", "stream_unauthorised": "Autorizzazione non riuscita durante il tentativo di connessione al flusso", + "template_error": "Errore durante l'esecuzione del modello. Esamina il registro per ulteriori informazioni.", "timeout": "Timeout durante il caricamento dell'URL", "unable_still_load": "Impossibile caricare un'immagine valida dall'URL dell'immagine fissa (ad es. host, URL non valido o errore di autenticazione). Esamina il registro per ulteriori informazioni.", "unknown": "Errore imprevisto" diff --git a/homeassistant/components/generic/translations/ja.json b/homeassistant/components/generic/translations/ja.json index a106cbf4cdc..f4fd7d8ec46 100644 --- a/homeassistant/components/generic/translations/ja.json +++ b/homeassistant/components/generic/translations/ja.json @@ -15,6 +15,7 @@ "stream_no_video": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u52d5\u753b\u304c\u3042\u308a\u307e\u305b\u3093", "stream_not_permitted": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u63a5\u7d9a\u3057\u3088\u3046\u3068\u3057\u3066\u3044\u308b\u9593\u3001\u64cd\u4f5c\u3067\u304d\u307e\u305b\u3093\u3002RTSP\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u30d7\u30ed\u30c8\u30b3\u30eb\u3092\u9593\u9055\u3048\u305f\uff1f", "stream_unauthorised": "\u30b9\u30c8\u30ea\u30fc\u30e0\u3078\u306e\u63a5\u7d9a\u6642\u306b\u3001\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "template_error": "\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u306e\u30ec\u30f3\u30c0\u30ea\u30f3\u30b0\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "timeout": "URL\u306e\u8aad\u307f\u8fbc\u307f\u4e2d\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", "unable_still_load": "\u9759\u6b62\u753b\u306eURL\u304b\u3089\u6709\u52b9\u306a\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u3080\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\uff08\u4f8b: \u7121\u52b9\u306a\u30db\u30b9\u30c8\u3001URL\u3001\u307e\u305f\u306f\u8a8d\u8a3c\u5931\u6557)\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" @@ -57,6 +58,7 @@ "stream_no_video": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u52d5\u753b\u304c\u3042\u308a\u307e\u305b\u3093", "stream_not_permitted": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u63a5\u7d9a\u3057\u3088\u3046\u3068\u3057\u3066\u3044\u308b\u9593\u3001\u64cd\u4f5c\u3067\u304d\u307e\u305b\u3093\u3002RTSP\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u30d7\u30ed\u30c8\u30b3\u30eb\u3092\u9593\u9055\u3048\u305f\uff1f", "stream_unauthorised": "\u30b9\u30c8\u30ea\u30fc\u30e0\u3078\u306e\u63a5\u7d9a\u6642\u306b\u3001\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "template_error": "\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u306e\u30ec\u30f3\u30c0\u30ea\u30f3\u30b0\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "timeout": "URL\u306e\u8aad\u307f\u8fbc\u307f\u4e2d\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", "unable_still_load": "\u9759\u6b62\u753b\u306eURL\u304b\u3089\u6709\u52b9\u306a\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u3080\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\uff08\u4f8b: \u7121\u52b9\u306a\u30db\u30b9\u30c8\u3001URL\u3001\u307e\u305f\u306f\u8a8d\u8a3c\u5931\u6557)\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" diff --git a/homeassistant/components/generic/translations/nl.json b/homeassistant/components/generic/translations/nl.json index 354f944148f..b7727190810 100644 --- a/homeassistant/components/generic/translations/nl.json +++ b/homeassistant/components/generic/translations/nl.json @@ -15,6 +15,7 @@ "stream_no_video": "Stream heeft geen video", "stream_not_permitted": "Operatie niet toegestaan bij poging om verbinding te maken met stream. Verkeerd RTSP transport protocol?", "stream_unauthorised": "Autorisatie mislukt bij poging om verbinding te maken met stream", + "template_error": "Fout bij het weergeven van sjabloon. Bekijk het logboek voor meer informatie.", "timeout": "Time-out tijdens het laden van URL", "unable_still_load": "Kan geen geldige afbeelding laden van stilstaande afbeelding URL (b.v. ongeldige host, URL of authenticatie fout). Bekijk het log voor meer informatie.", "unknown": "Onverwachte fout" @@ -57,6 +58,7 @@ "stream_no_video": "Stream heeft geen video", "stream_not_permitted": "Operatie niet toegestaan bij poging om verbinding te maken met stream. Verkeerd RTSP transport protocol?", "stream_unauthorised": "Autorisatie mislukt bij poging om verbinding te maken met stream", + "template_error": "Fout bij het weergeven van sjabloon. Bekijk het logboek voor meer informatie.", "timeout": "Time-out tijdens het laden van URL", "unable_still_load": "Kan geen geldige afbeelding laden van stilstaande afbeelding URL (b.v. ongeldige host, URL of authenticatie fout). Bekijk het log voor meer informatie.", "unknown": "Onverwachte fout" diff --git a/homeassistant/components/generic/translations/no.json b/homeassistant/components/generic/translations/no.json index 0c228e314d2..72355d002ed 100644 --- a/homeassistant/components/generic/translations/no.json +++ b/homeassistant/components/generic/translations/no.json @@ -15,6 +15,7 @@ "stream_no_video": "Stream har ingen video", "stream_not_permitted": "Operasjon er ikke tillatt mens du pr\u00f8ver \u00e5 koble til str\u00f8m. Feil RTSP-transportprotokoll?", "stream_unauthorised": "Autorisasjonen mislyktes under fors\u00f8k p\u00e5 \u00e5 koble til str\u00f8mmen", + "template_error": "Feil ved gjengivelse av mal. Se gjennom loggen for mer informasjon.", "timeout": "Tidsavbrudd under innlasting av URL", "unable_still_load": "Kan ikke laste inn gyldig bilde fra URL-adresse for stillbilde (f.eks. ugyldig verts-, URL- eller godkjenningsfeil). Se gjennom loggen hvis du vil ha mer informasjon.", "unknown": "Uventet feil" @@ -57,6 +58,7 @@ "stream_no_video": "Stream har ingen video", "stream_not_permitted": "Operasjon er ikke tillatt mens du pr\u00f8ver \u00e5 koble til str\u00f8m. Feil RTSP-transportprotokoll?", "stream_unauthorised": "Autorisasjonen mislyktes under fors\u00f8k p\u00e5 \u00e5 koble til str\u00f8mmen", + "template_error": "Feil ved gjengivelse av mal. Se gjennom loggen for mer informasjon.", "timeout": "Tidsavbrudd under innlasting av URL", "unable_still_load": "Kan ikke laste inn gyldig bilde fra URL-adresse for stillbilde (f.eks. ugyldig verts-, URL- eller godkjenningsfeil). Se gjennom loggen hvis du vil ha mer informasjon.", "unknown": "Uventet feil" diff --git a/homeassistant/components/generic/translations/pl.json b/homeassistant/components/generic/translations/pl.json index 3669fd78e3f..81817faf236 100644 --- a/homeassistant/components/generic/translations/pl.json +++ b/homeassistant/components/generic/translations/pl.json @@ -15,6 +15,7 @@ "stream_no_video": "Strumie\u0144 nie zawiera wideo", "stream_not_permitted": "Operacja nie jest dozwolona podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem. Z\u0142y protok\u00f3\u0142 transportowy RTSP?", "stream_unauthorised": "Autoryzacja nie powiod\u0142a si\u0119 podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem", + "template_error": "B\u0142\u0105d renderowania szablonu. Przejrzyj log, aby uzyska\u0107 wi\u0119cej informacji.", "timeout": "Przekroczono limit czasu podczas \u0142adowania adresu URL", "unable_still_load": "Nie mo\u017cna za\u0142adowa\u0107 prawid\u0142owego obrazu z adresu URL nieruchomego obrazu (np. nieprawid\u0142owy host, adres URL lub b\u0142\u0105d uwierzytelniania). Przejrzyj logi, aby uzyska\u0107 wi\u0119cej informacji.", "unknown": "Nieoczekiwany b\u0142\u0105d" @@ -57,6 +58,7 @@ "stream_no_video": "Strumie\u0144 nie zawiera wideo", "stream_not_permitted": "Operacja nie jest dozwolona podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem. Z\u0142y protok\u00f3\u0142 transportowy RTSP?", "stream_unauthorised": "Autoryzacja nie powiod\u0142a si\u0119 podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem", + "template_error": "B\u0142\u0105d renderowania szablonu. Przejrzyj log, aby uzyska\u0107 wi\u0119cej informacji.", "timeout": "Przekroczono limit czasu podczas \u0142adowania adresu URL", "unable_still_load": "Nie mo\u017cna za\u0142adowa\u0107 prawid\u0142owego obrazu z adresu URL nieruchomego obrazu (np. nieprawid\u0142owy host, adres URL lub b\u0142\u0105d uwierzytelniania). Przejrzyj logi, aby uzyska\u0107 wi\u0119cej informacji.", "unknown": "Nieoczekiwany b\u0142\u0105d" diff --git a/homeassistant/components/generic/translations/pt-BR.json b/homeassistant/components/generic/translations/pt-BR.json index ea1ede92f22..1a61cdeac97 100644 --- a/homeassistant/components/generic/translations/pt-BR.json +++ b/homeassistant/components/generic/translations/pt-BR.json @@ -15,6 +15,7 @@ "stream_no_video": "A stream n\u00e3o tem v\u00eddeo", "stream_not_permitted": "Opera\u00e7\u00e3o n\u00e3o permitida ao tentar se conectar a stream. Protocolo RTSP errado?", "stream_unauthorised": "Falha na autoriza\u00e7\u00e3o ao tentar se conectar a stream", + "template_error": "Erro ao renderizar o modelo. Revise o registro para obter mais informa\u00e7\u00f5es.", "timeout": "Tempo limite ao carregar a URL", "unable_still_load": "N\u00e3o foi poss\u00edvel carregar uma imagem v\u00e1lida do URL da imagem est\u00e1tica (por exemplo, host inv\u00e1lido, URL ou falha de autentica\u00e7\u00e3o). Revise o log para obter mais informa\u00e7\u00f5es.", "unknown": "Erro inesperado" @@ -57,6 +58,7 @@ "stream_no_video": "A stream n\u00e3o tem v\u00eddeo", "stream_not_permitted": "Opera\u00e7\u00e3o n\u00e3o permitida ao tentar se conectar a stream. Protocolo RTSP errado?", "stream_unauthorised": "Falha na autoriza\u00e7\u00e3o ao tentar se conectar a stream", + "template_error": "Erro ao renderizar o modelo. Revise o registro para obter mais informa\u00e7\u00f5es.", "timeout": "Tempo limite ao carregar a URL", "unable_still_load": "N\u00e3o foi poss\u00edvel carregar uma imagem v\u00e1lida do URL da imagem est\u00e1tica (por exemplo, host inv\u00e1lido, URL ou falha de autentica\u00e7\u00e3o). Revise o log para obter mais informa\u00e7\u00f5es.", "unknown": "Erro inesperado" diff --git a/homeassistant/components/generic/translations/sv.json b/homeassistant/components/generic/translations/sv.json index 78033d17c6e..62b30963a50 100644 --- a/homeassistant/components/generic/translations/sv.json +++ b/homeassistant/components/generic/translations/sv.json @@ -2,6 +2,24 @@ "config": { "abort": { "no_devices_found": "Inga enheter hittades i n\u00e4tverket" + }, + "step": { + "user": { + "data": { + "authentication": "Autentiseringen", + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "authentication": "Autentiseringen", + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/generic/translations/tr.json b/homeassistant/components/generic/translations/tr.json index 7b6ab65f948..d439c559aa5 100644 --- a/homeassistant/components/generic/translations/tr.json +++ b/homeassistant/components/generic/translations/tr.json @@ -15,6 +15,7 @@ "stream_no_video": "Ak\u0131\u015fta video yok", "stream_not_permitted": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken i\u015fleme izin verilmiyor. Yanl\u0131\u015f RTSP aktar\u0131m protokol\u00fc?", "stream_unauthorised": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken yetkilendirme ba\u015far\u0131s\u0131z oldu", + "template_error": "\u015eablon olu\u015fturma hatas\u0131. Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "timeout": "URL y\u00fcklenirken zaman a\u015f\u0131m\u0131", "unable_still_load": "Hareketsiz resim URL'sinden ge\u00e7erli resim y\u00fcklenemiyor (\u00f6r. ge\u00e7ersiz ana bilgisayar, URL veya kimlik do\u011frulama hatas\u0131). Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "unknown": "Beklenmeyen hata" @@ -57,6 +58,7 @@ "stream_no_video": "Ak\u0131\u015fta video yok", "stream_not_permitted": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken i\u015fleme izin verilmiyor. Yanl\u0131\u015f RTSP aktar\u0131m protokol\u00fc?", "stream_unauthorised": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken yetkilendirme ba\u015far\u0131s\u0131z oldu", + "template_error": "\u015eablon olu\u015fturma hatas\u0131. Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "timeout": "URL y\u00fcklenirken zaman a\u015f\u0131m\u0131", "unable_still_load": "Hareketsiz resim URL'sinden ge\u00e7erli resim y\u00fcklenemiyor (\u00f6r. ge\u00e7ersiz ana bilgisayar, URL veya kimlik do\u011frulama hatas\u0131). Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "unknown": "Beklenmeyen hata" diff --git a/homeassistant/components/generic/translations/zh-Hant.json b/homeassistant/components/generic/translations/zh-Hant.json index d1079724252..595bf019f64 100644 --- a/homeassistant/components/generic/translations/zh-Hant.json +++ b/homeassistant/components/generic/translations/zh-Hant.json @@ -15,6 +15,7 @@ "stream_no_video": "\u4e32\u6d41\u6c92\u6709\u5f71\u50cf", "stream_not_permitted": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u4e0d\u5141\u8a31\u64cd\u4f5c\u3002\u8f38\u5165\u932f\u8aa4\u7684 RTSP \u50b3\u8f38\u5354\u5b9a\uff1f", "stream_unauthorised": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u8a8d\u8b49\u5931\u6557", + "template_error": "\u6a21\u7248\u6e32\u67d3\u932f\u8aa4\u3001\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8cc7\u6599\u3002", "timeout": "\u8f09\u5165 URL \u903e\u6642\u6642\u9593", "unable_still_load": "\u7121\u6cd5\u7531\u8a2d\u5b9a\u975c\u614b\u5f71\u50cf URL \u8f09\u5165\u6709\u6548\u5f71\u50cf\uff08\u4f8b\u5982\uff1a\u7121\u6548\u4e3b\u6a5f\u3001URL \u6216\u8a8d\u8b49\u5931\u6557\uff09\u3002\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8a0a\u606f\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" @@ -57,6 +58,7 @@ "stream_no_video": "\u4e32\u6d41\u6c92\u6709\u5f71\u50cf", "stream_not_permitted": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u4e0d\u5141\u8a31\u64cd\u4f5c\u3002\u8f38\u5165\u932f\u8aa4\u7684 RTSP \u50b3\u8f38\u5354\u5b9a\uff1f", "stream_unauthorised": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u8a8d\u8b49\u5931\u6557", + "template_error": "\u6a21\u7248\u6e32\u67d3\u932f\u8aa4\u3001\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8cc7\u6599\u3002", "timeout": "\u8f09\u5165 URL \u903e\u6642\u6642\u9593", "unable_still_load": "\u7121\u6cd5\u7531\u8a2d\u5b9a\u975c\u614b\u5f71\u50cf URL \u8f09\u5165\u6709\u6548\u5f71\u50cf\uff08\u4f8b\u5982\uff1a\u7121\u6548\u4e3b\u6a5f\u3001URL \u6216\u8a8d\u8b49\u5931\u6557\uff09\u3002\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8a0a\u606f\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" diff --git a/homeassistant/components/geocaching/config_flow.py b/homeassistant/components/geocaching/config_flow.py index 83c9ed17586..56fa56a1f82 100644 --- a/homeassistant/components/geocaching/config_flow.py +++ b/homeassistant/components/geocaching/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Geocaching.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -24,9 +25,9 @@ class GeocachingFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Return logger.""" return logging.getLogger(__name__) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + 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(user_input=user_input) + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/geocaching/oauth.py b/homeassistant/components/geocaching/oauth.py index e0120344cdb..848c4fce66c 100644 --- a/homeassistant/components/geocaching/oauth.py +++ b/homeassistant/components/geocaching/oauth.py @@ -1,7 +1,7 @@ """oAuth2 functions and classes for Geocaching API integration.""" from __future__ import annotations -from typing import Any, cast +from typing import Any from homeassistant.components.application_credentials import ( AuthImplementation, @@ -9,7 +9,6 @@ from homeassistant.components.application_credentials import ( ClientCredential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ENVIRONMENT, ENVIRONMENT_URLS @@ -65,13 +64,3 @@ class GeocachingOAuth2Implementation(AuthImplementation): new_token = await self._token_request(data) return {**token, **new_token} - - async def _token_request(self, data: dict) -> dict: - """Make a token request.""" - data["client_id"] = self.client_id - if self.client_secret is not None: - data["client_secret"] = self.client_secret - session = async_get_clientsession(self.hass) - resp = await session.post(ENVIRONMENT_URLS[ENVIRONMENT]["token_url"], data=data) - resp.raise_for_status() - return cast(dict, await resp.json()) diff --git a/homeassistant/components/geocaching/translations/es.json b/homeassistant/components/geocaching/translations/es.json index 8b03adca234..8fd4800c588 100644 --- a/homeassistant/components/geocaching/translations/es.json +++ b/homeassistant/components/geocaching/translations/es.json @@ -5,6 +5,7 @@ "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", "authorize_url_timeout": "Tiempo de espera agotado durante la generaci\u00f3n de la URL de autorizaci\u00f3n.", "missing_configuration": "El componente no est\u00e1 configurado. Mira su documentaci\u00f3n.", + "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [compruebe la secci\u00f3n de ayuda]({docs_url})", "oauth_error": "Se han recibido datos token inv\u00e1lidos.", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, diff --git a/homeassistant/components/geocaching/translations/pt-BR.json b/homeassistant/components/geocaching/translations/pt-BR.json index 4a6c20919d0..3767468530c 100644 --- a/homeassistant/components/geocaching/translations/pt-BR.json +++ b/homeassistant/components/geocaching/translations/pt-BR.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "A conta j\u00e1 est\u00e1 configurada", + "already_configured": "A conta j\u00e1 foi configurada", "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "Nenhuma URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre este erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", - "oauth_error": "Dados de token inv\u00e1lidos recebidos.", + "no_url_available": "N\u00e3o h\u00e1 URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", + "oauth_error": "Dados de token recebidos inv\u00e1lidos.", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "create_entry": { diff --git a/homeassistant/components/geocaching/translations/sv.json b/homeassistant/components/geocaching/translations/sv.json new file mode 100644 index 00000000000..d8622ba37fb --- /dev/null +++ b/homeassistant/components/geocaching/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "missing_configuration": "Komponenten har inte konfigurerats. F\u00f6lj dokumentationen.", + "no_url_available": "Ingen webbadress tillg\u00e4nglig. F\u00f6r information om detta fel, [kolla hj\u00e4lpavsnittet]({docs_url})", + "oauth_error": "Mottog ogiltiga tokendata.", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "create_entry": { + "default": "Autentiserats" + }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + }, + "reauth_confirm": { + "title": "\u00c5terautenticera integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/translations/es.json b/homeassistant/components/geofency/translations/es.json index 22aa30cc182..6726a4d2e3b 100644 --- a/homeassistant/components/geofency/translations/es.json +++ b/homeassistant/components/geofency/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cloud_not_connected": "No est\u00e1 conectado a Home Assistant Cloud.", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, diff --git a/homeassistant/components/geofency/translations/sv.json b/homeassistant/components/geofency/translations/sv.json index 453b33533ce..8b48a30ce8b 100644 --- a/homeassistant/components/geofency/translations/sv.json +++ b/homeassistant/components/geofency/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "cloud_not_connected": "Ej ansluten till Home Assistant Cloud." + }, "create_entry": { "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera webhook funktionen i Geofency.\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information." }, diff --git a/homeassistant/components/geonetnz_quakes/translations/bg.json b/homeassistant/components/geonetnz_quakes/translations/bg.json index 8b4d3e91f2c..23e5c0241d9 100644 --- a/homeassistant/components/geonetnz_quakes/translations/bg.json +++ b/homeassistant/components/geonetnz_quakes/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/geonetnz_volcano/translations/sv.json b/homeassistant/components/geonetnz_volcano/translations/sv.json index 0ad4f7f0853..65e867f8269 100644 --- a/homeassistant/components/geonetnz_volcano/translations/sv.json +++ b/homeassistant/components/geonetnz_volcano/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Platsen \u00e4r redan konfigurerad" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/github/translations/es.json b/homeassistant/components/github/translations/es.json index d53004c3cb5..37bf1d4689e 100644 --- a/homeassistant/components/github/translations/es.json +++ b/homeassistant/components/github/translations/es.json @@ -4,6 +4,9 @@ "already_configured": "El servicio ya est\u00e1 configurado", "could_not_register": "No se pudo registrar la integraci\u00f3n con GitHub" }, + "progress": { + "wait_for_device": "1. Abra {url}\n 2.Pegue la siguiente clave para autorizar la integraci\u00f3n:\n ```\n {code}\n ```\n" + }, "step": { "repositories": { "data": { diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index 6272015e73c..571214deb20 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -3,17 +3,12 @@ from datetime import timedelta import logging from glances_api import Glances, exceptions -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, - CONF_PASSWORD, - CONF_PORT, CONF_SCAN_INTERVAL, - CONF_SSL, - CONF_USERNAME, CONF_VERIFY_SSL, Platform, ) @@ -23,55 +18,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_VERSION, - DATA_UPDATED, - DEFAULT_HOST, - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DEFAULT_VERSION, - DOMAIN, -) +from .const import DATA_UPDATED, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -GLANCES_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]), - } - ) -) - -CONFIG_SCHEMA = vol.Schema( - vol.All(cv.deprecated(DOMAIN), {DOMAIN: vol.All(cv.ensure_list, [GLANCES_SCHEMA])}), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Configure Glances using config flow only.""" - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 1ee7c2fa476..a4a345116eb 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Glances.""" +from __future__ import annotations + import glances_api import voluptuous as vol @@ -59,7 +61,9 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> GlancesOptionsFlowHandler: """Get the options flow for this handler.""" return GlancesOptionsFlowHandler(config_entry) @@ -82,16 +86,11 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, import_config): - """Import from Glances sensor config.""" - - return await self.async_step_user(user_input=import_config) - class GlancesOptionsFlowHandler(config_entries.OptionsFlow): """Handle Glances client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Glances options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index e5a8f1424c2..d28c7395a43 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -4,7 +4,11 @@ from __future__ import annotations from dataclasses import dataclass import sys -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import DATA_GIBIBYTES, DATA_MEBIBYTES, PERCENTAGE, TEMP_CELSIUS DOMAIN = "glances" @@ -40,6 +44,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="used percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="disk_use", @@ -47,6 +52,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="used", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="disk_free", @@ -54,6 +60,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="free", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="memory_use_percent", @@ -61,6 +68,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="RAM used percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="memory_use", @@ -68,6 +76,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="RAM used", native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="memory_free", @@ -75,6 +84,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="RAM free", native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="swap_use_percent", @@ -82,6 +92,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Swap used percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="swap_use", @@ -89,6 +100,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Swap used", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="swap_free", @@ -96,41 +108,42 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Swap free", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", - native_unit_of_measurement="15 min", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", - native_unit_of_measurement="Count", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", - native_unit_of_measurement="Count", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", - native_unit_of_measurement="Count", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", - native_unit_of_measurement="Count", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="cpu_use_percent", @@ -138,6 +151,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="CPU used", native_unit_of_measurement=PERCENTAGE, icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="temperature_core", @@ -145,6 +159,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="temperature_hdd", @@ -152,6 +167,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="fan_speed", @@ -159,6 +175,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Fan speed", native_unit_of_measurement="RPM", icon="mdi:fan", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="battery", @@ -166,13 +183,14 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Charge", native_unit_of_measurement=PERCENTAGE, icon="mdi:battery", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", - native_unit_of_measurement="", icon="mdi:docker", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="docker_cpu_use", @@ -180,6 +198,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Containers CPU used", native_unit_of_measurement=PERCENTAGE, icon="mdi:docker", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="docker_memory_use", @@ -187,17 +206,20 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Containers RAM used", native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:docker", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index a907dd1695a..7dfd0c503ef 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -4,8 +4,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import GlancesData from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES, GlancesSensorEntityDescription @@ -67,11 +69,11 @@ class GlancesSensor(SensorEntity): def __init__( self, - glances_data, - name, - sensor_name_prefix, + glances_data: GlancesData, + name: str, + sensor_name_prefix: str, description: GlancesSensorEntityDescription, - ): + ) -> None: """Initialize the sensor.""" self.glances_data = glances_data self._sensor_name_prefix = sensor_name_prefix @@ -80,6 +82,11 @@ class GlancesSensor(SensorEntity): self.entity_description = description self._attr_name = f"{name} {sensor_name_prefix} {description.name_suffix}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, glances_data.config_entry.entry_id)}, + manufacturer="Glances", + name=name, + ) @property def unique_id(self): diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index e014c4780ad..ea292a651c2 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,70 +1,31 @@ """The Goal Zero Yeti integration.""" from __future__ import annotations -import logging - from goalzero import Yeti, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) - -from .const import ( - ATTRIBUTION, - DATA_KEY_API, - DATA_KEY_COORDINATOR, - DOMAIN, - MANUFACTURER, - MIN_TIME_BETWEEN_UPDATES, -) - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import GoalZeroDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" - name = entry.data[CONF_NAME] - host = entry.data[CONF_HOST] - - api = Yeti(host, async_get_clientsession(hass)) + api = Yeti(entry.data[CONF_HOST], async_get_clientsession(hass)) try: await api.init_connect() except exceptions.ConnectError as ex: raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex - async def async_update_data() -> None: - """Fetch data from API endpoint.""" - try: - await api.get_state() - except exceptions.ConnectError as err: - raise UpdateFailed("Failed to communicate with device") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=name, - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) + coordinator = GoalZeroDataUpdateCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_KEY_API: api, - DATA_KEY_COORDINATOR: coordinator, - } - + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -72,38 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class YetiEntity(CoordinatorEntity): - """Representation of a Goal Zero Yeti entity.""" - - _attr_attribution = ATTRIBUTION - - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti entity.""" - super().__init__(coordinator) - self.api = api - self._name = name - self._server_unique_id = server_unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information of the entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.api.sysdata["macAddress"])}, - identifiers={(DOMAIN, self._server_unique_id)}, - manufacturer=MANUFACTURER, - model=self.api.sysdata[ATTR_MODEL], - name=self._name, - sw_version=self.api.data["firmwareVersion"], - ) diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 56d812c5923..c4219b51e6c 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -3,22 +3,18 @@ from __future__ import annotations from typing import cast -from goalzero import Yeti - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from .const import DOMAIN +from .entity import GoalZeroEntity PARALLEL_UPDATES = 0 @@ -51,38 +47,19 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Goal Zero Yeti sensor.""" - name = entry.data[CONF_NAME] - goalzero_data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - YetiBinarySensor( - goalzero_data[DATA_KEY_API], - goalzero_data[DATA_KEY_COORDINATOR], - name, + GoalZeroBinarySensor( + hass.data[DOMAIN][entry.entry_id], description, - entry.entry_id, ) for description in BINARY_SENSOR_TYPES ) -class YetiBinarySensor(YetiEntity, BinarySensorEntity): +class GoalZeroBinarySensor(GoalZeroEntity, BinarySensorEntity): """Representation of a Goal Zero Yeti sensor.""" - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - description: BinarySensorEntityDescription, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti sensor.""" - super().__init__(api, coordinator, name, server_unique_id) - self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{server_unique_id}/{description.key}" - @property def is_on(self) -> bool: """Return True if the service is on.""" - return cast(bool, self.api.data[self.entity_description.key] == 1) + return cast(bool, self._api.data[self.entity_description.key] == 1) diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index fef1636005d..280a70abbf1 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -1,12 +1,12 @@ """Constants for the Goal Zero Yeti integration.""" -from datetime import timedelta +import logging +from typing import Final ATTRIBUTION = "Data provided by Goal Zero" ATTR_DEFAULT_ENABLED = "default_enabled" -DATA_KEY_COORDINATOR = "coordinator" -DOMAIN = "goalzero" +DOMAIN: Final = "goalzero" DEFAULT_NAME = "Yeti" -DATA_KEY_API = "api" MANUFACTURER = "Goal Zero" -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/goalzero/coordinator.py b/homeassistant/components/goalzero/coordinator.py new file mode 100644 index 00000000000..416b420f29d --- /dev/null +++ b/homeassistant/components/goalzero/coordinator.py @@ -0,0 +1,34 @@ +"""Data update coordinator for the Goal zero integration.""" + +from datetime import timedelta + +from goalzero import Yeti, exceptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class GoalZeroDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Goal zero integration.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, api: Yeti) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.api = api + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + try: + await self.api.get_state() + except exceptions.ConnectError as err: + raise UpdateFailed("Failed to communicate with device") from err diff --git a/homeassistant/components/goalzero/entity.py b/homeassistant/components/goalzero/entity.py new file mode 100644 index 00000000000..8c696ce1377 --- /dev/null +++ b/homeassistant/components/goalzero/entity.py @@ -0,0 +1,47 @@ +"""Entity representing a Goal Zero Yeti device.""" + +from goalzero import Yeti + +from homeassistant.const import ATTR_MODEL, CONF_NAME +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER +from .coordinator import GoalZeroDataUpdateCoordinator + + +class GoalZeroEntity(CoordinatorEntity[GoalZeroDataUpdateCoordinator]): + """Representation of a Goal Zero Yeti entity.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: GoalZeroDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize a Goal Zero Yeti entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = ( + f"{coordinator.config_entry.data[CONF_NAME]} {description.name}" + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}/{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information of the entity.""" + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._api.sysdata["macAddress"])}, + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + manufacturer=MANUFACTURER, + model=self._api.sysdata[ATTR_MODEL], + name=self.coordinator.config_entry.data[CONF_NAME], + sw_version=self._api.data["firmwareVersion"], + ) + + @property + def _api(self) -> Yeti: + """Return api from coordinator.""" + return self.coordinator.api diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 464cd7e5f31..ef95578820d 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -3,8 +3,6 @@ from __future__ import annotations from typing import cast -from goalzero import Yeti - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, @@ -28,10 +25,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from .const import DOMAIN +from .entity import GoalZeroEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -139,39 +135,19 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Goal Zero Yeti sensor.""" - name = entry.data[CONF_NAME] - goalzero_data = hass.data[DOMAIN][entry.entry_id] - sensors = [ - YetiSensor( - goalzero_data[DATA_KEY_API], - goalzero_data[DATA_KEY_COORDINATOR], - name, + async_add_entities( + GoalZeroSensor( + hass.data[DOMAIN][entry.entry_id], description, - entry.entry_id, ) for description in SENSOR_TYPES - ] - async_add_entities(sensors, True) + ) -class YetiSensor(YetiEntity, SensorEntity): +class GoalZeroSensor(GoalZeroEntity, SensorEntity): """Representation of a Goal Zero Yeti sensor.""" - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - description: SensorEntityDescription, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti sensor.""" - super().__init__(api, coordinator, name, server_unique_id) - self._attr_name = f"{name} {description.name}" - self.entity_description = description - self._attr_unique_id = f"{server_unique_id}/{description.key}" - @property def native_value(self) -> StateType: """Return the state.""" - return cast(StateType, self.api.data[self.entity_description.key]) + return cast(StateType, self._api.data[self.entity_description.key]) diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index b45e3b0f89a..9a58cb385b6 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -3,17 +3,13 @@ from __future__ import annotations from typing import Any, cast -from goalzero import Yeti - from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from .const import DOMAIN +from .entity import GoalZeroEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -35,50 +31,31 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Goal Zero Yeti switch.""" - name = entry.data[CONF_NAME] - goalzero_data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - YetiSwitch( - goalzero_data[DATA_KEY_API], - goalzero_data[DATA_KEY_COORDINATOR], - name, + GoalZeroSwitch( + hass.data[DOMAIN][entry.entry_id], description, - entry.entry_id, ) for description in SWITCH_TYPES ) -class YetiSwitch(YetiEntity, SwitchEntity): +class GoalZeroSwitch(GoalZeroEntity, SwitchEntity): """Representation of a Goal Zero Yeti switch.""" - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - description: SwitchEntityDescription, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti switch.""" - super().__init__(api, coordinator, name, server_unique_id) - self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{server_unique_id}/{description.key}" - @property def is_on(self) -> bool: """Return state of the switch.""" - return cast(bool, self.api.data[self.entity_description.key] == 1) + return cast(bool, self._api.data[self.entity_description.key] == 1) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" payload = {self.entity_description.key: 0} - await self.api.post_state(payload=payload) + await self._api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" payload = {self.entity_description.key: 1} - await self.api.post_state(payload=payload) + await self._api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index e97b62102c4..344e8473984 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -1,12 +1,14 @@ """Config flow for Gogogate2.""" +from __future__ import annotations + import dataclasses import re +from typing import Any from ismartgate.common import AbstractInfoResponse, ApiError from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode import voluptuous as vol -from homeassistant import data_entry_flow from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( @@ -15,6 +17,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.data_entry_flow import AbortFlow, FlowResult from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN @@ -30,28 +33,26 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" - self._ip_address = None - self._device_type = None + self._ip_address: str | None = None + self._device_type: str | None = None async def async_step_homekit( self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle homekit discovery.""" await self.async_set_unique_id( discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] ) return await self._async_discovery_handler(discovery_info.host) - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) return await self._async_discovery_handler(discovery_info.ip) - async def _async_discovery_handler(self, ip_address): + async def _async_discovery_handler(self, ip_address: str) -> FlowResult: """Start the user flow from any discovery.""" self.context[CONF_IP_ADDRESS] = ip_address self._abort_if_unique_id_configured({CONF_IP_ADDRESS: ip_address}) @@ -61,12 +62,14 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): self._ip_address = ip_address for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_IP_ADDRESS) == self._ip_address: - raise data_entry_flow.AbortFlow("already_in_progress") + raise AbortFlow("already_in_progress") self._device_type = DEVICE_TYPE_ISMARTGATE return await self.async_step_user() - async def async_step_user(self, user_input: dict = None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user initiated flow.""" user_input = user_input or {} errors = {} diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index af3bd1c7530..f0039d85295 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,6 +1,8 @@ """Support for Gogogate2 garage Doors.""" from __future__ import annotations +from typing import Any + from ismartgate.common import ( AbstractDoor, DoorStatus, @@ -60,12 +62,12 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): ) @property - def name(self): + def name(self) -> str | None: """Return the name of the door.""" return self.door.name @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return true if cover is closed, else False.""" door_status = self.door_status if door_status == DoorStatus.OPENED: @@ -75,21 +77,21 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): return None @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self.door_status == TransitionDoorStatus.CLOSING @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self.door_status == TransitionDoorStatus.OPENING - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the door.""" await self._api.async_open_door(self._door_id) await self.coordinator.async_refresh() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the door.""" await self._api.async_close_door(self._door_id) await self.coordinator.async_refresh() diff --git a/homeassistant/components/gogogate2/translations/sv.json b/homeassistant/components/gogogate2/translations/sv.json new file mode 100644 index 00000000000..f7461922566 --- /dev/null +++ b/homeassistant/components/gogogate2/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 80c7885f26c..00d8d9d0cae 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -40,24 +40,24 @@ NUMBERS = ( name="Grid export limit", icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - step=100, - min_value=0, - max_value=10000, + native_step=100, + native_min_value=0, + native_max_value=10000, ), GoodweNumberEntityDescription( key="battery_discharge_depth", name="Depth of discharge (on-grid)", icon="mdi:battery-arrow-down", entity_category=EntityCategory.CONFIG, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, getter=lambda inv: inv.get_ongrid_battery_dod(), setter=lambda inv, val: inv.set_ongrid_battery_dod(val), - step=1, - min_value=0, - max_value=99, + native_step=1, + native_min_value=0, + native_max_value=99, ), ) @@ -105,12 +105,12 @@ class InverterNumberEntity(NumberEntity): self.entity_description = description self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}" self._attr_device_info = device_info - self._attr_value = float(current_value) + self._attr_native_value = float(current_value) self._inverter: Inverter = inverter - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" if self.entity_description.setter: await self.entity_description.setter(self._inverter, int(value)) - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 96a8ef49af3..6dcdc6e8cb1 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, cast from goodwe import Inverter, Sensor, SensorKind @@ -49,7 +49,7 @@ _MAIN_SENSORS = ( "e_bat_discharge_total", ) -_ICONS = { +_ICONS: dict[SensorKind, str] = { SensorKind.PV: "mdi:solar-power", SensorKind.AC: "mdi:power-plug-outline", SensorKind.UPS: "mdi:power-plug-off-outline", @@ -62,10 +62,13 @@ _ICONS = { class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" - value: Callable[[str, Any, Any], Any] = lambda sensor, prev, val: val + value: Callable[[Any, Any], Any] = lambda prev, val: val + available: Callable[ + [CoordinatorEntity], bool + ] = lambda entity: entity.coordinator.last_update_success -_DESCRIPTIONS = { +_DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { "A": GoodweSensorEntityDescription( key="A", device_class=SensorDeviceClass.CURRENT, @@ -89,7 +92,8 @@ _DESCRIPTIONS = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - value=lambda sensor, prev, val: prev if "total" in sensor and not val else val, + value=lambda prev, val: prev if not val else val, + available=lambda entity: entity.coordinator.data is not None, ), "C": GoodweSensorEntityDescription( key="C", @@ -167,10 +171,22 @@ class InverterSensor(CoordinatorEntity, SensorEntity): @property def native_value(self): """Return the value reported by the sensor.""" - value = self.entity_description.value( - self._sensor.id_, + value = cast(GoodweSensorEntityDescription, self.entity_description).value( self._previous_value, self.coordinator.data.get(self._sensor.id_, self._previous_value), ) self._previous_value = value return value + + @property + def available(self) -> bool: + """Return if entity is available. + + We delegate the behavior to entity description lambda, since + some sensors (like energy produced today) should report themselves + as available even when the (non-battery) pv inverter is off-line during night + and most of the sensors are actually unavailable. + """ + return cast(GoodweSensorEntityDescription, self.entity_description).available( + self + ) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 2a40bfe7043..5553350aa23 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,7 +1,6 @@ """Support for Google - Calendar Event Devices.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from datetime import datetime, timedelta import logging @@ -9,8 +8,8 @@ from typing import Any import aiohttp from gcal_sync.api import GoogleCalendarService -from gcal_sync.exceptions import ApiException -from gcal_sync.model import Calendar, DateOrDatetime, Event +from gcal_sync.exceptions import ApiException, AuthException +from gcal_sync.model import DateOrDatetime, Event from oauth2client.file import Storage import voluptuous as vol from voluptuous.error import Error as VoluptuousError @@ -31,15 +30,10 @@ from homeassistant.const import ( CONF_OFFSET, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.typing import ConfigType @@ -49,8 +43,17 @@ from .const import ( DATA_CONFIG, DATA_SERVICE, DEVICE_AUTH_IMPL, - DISCOVER_CALENDAR, DOMAIN, + EVENT_DESCRIPTION, + EVENT_END_DATE, + EVENT_END_DATETIME, + EVENT_IN, + EVENT_IN_DAYS, + EVENT_IN_WEEKS, + EVENT_START_DATE, + EVENT_START_DATETIME, + EVENT_SUMMARY, + EVENT_TYPES_CONF, FeatureAccess, ) @@ -69,24 +72,11 @@ CONF_MAX_RESULTS = "max_results" DEFAULT_CONF_OFFSET = "!!" EVENT_CALENDAR_ID = "calendar_id" -EVENT_DESCRIPTION = "description" -EVENT_END_CONF = "end" -EVENT_END_DATE = "end_date" -EVENT_END_DATETIME = "end_date_time" -EVENT_IN = "in" -EVENT_IN_DAYS = "days" -EVENT_IN_WEEKS = "weeks" -EVENT_START_CONF = "start" -EVENT_START_DATE = "start_date" -EVENT_START_DATETIME = "start_date_time" -EVENT_SUMMARY = "summary" -EVENT_TYPES_CONF = "event_types" NOTIFICATION_ID = "google_calendar_notification" NOTIFICATION_TITLE = "Google Calendar Setup" GROUP_NAME_ALL_CALENDARS = "Google Calendar Sensors" -SERVICE_SCAN_CALENDARS = "scan_for_calendars" SERVICE_ADD_EVENT = "add_event" YAML_DEVICES = f"{DOMAIN}_calendars.yaml" @@ -147,17 +137,31 @@ _EVENT_IN_TYPES = vol.Schema( } ) -ADD_EVENT_SERVICE_SCHEMA = vol.Schema( +ADD_EVENT_SERVICE_SCHEMA = vol.All( + cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), { vol.Required(EVENT_CALENDAR_ID): cv.string, vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, - vol.Exclusive(EVENT_START_DATE, EVENT_START_CONF): cv.date, - vol.Exclusive(EVENT_END_DATE, EVENT_END_CONF): cv.date, - vol.Exclusive(EVENT_START_DATETIME, EVENT_START_CONF): cv.datetime, - vol.Exclusive(EVENT_END_DATETIME, EVENT_END_CONF): cv.datetime, - vol.Exclusive(EVENT_IN, EVENT_START_CONF, EVENT_END_CONF): _EVENT_IN_TYPES, - } + vol.Inclusive( + EVENT_START_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_END_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_START_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Inclusive( + EVENT_END_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Optional(EVENT_IN): _EVENT_IN_TYPES, + }, ) @@ -217,6 +221,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google from a config entry.""" hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} + implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -246,9 +252,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: calendar_service = GoogleCalendarService( ApiAuthImpl(async_get_clientsession(hass), session) ) - hass.data[DOMAIN][DATA_SERVICE] = calendar_service + hass.data[DOMAIN][entry.entry_id][DATA_SERVICE] = calendar_service + + if entry.unique_id is None: + try: + primary_calendar = await calendar_service.async_get_calendar("primary") + except AuthException as err: + raise ConfigEntryAuthFailed from err + except ApiException as err: + raise ConfigEntryNotReady from err + else: + hass.config_entries.async_update_entry(entry, unique_id=primary_calendar.id) - await async_setup_services(hass, calendar_service) # Only expose the add event service if we have the correct permissions if get_feature_access(hass, entry) is FeatureAccess.read_write: await async_setup_add_event_service(hass, calendar_service) @@ -269,7 +284,9 @@ def async_entry_has_scopes(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) + 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_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -278,57 +295,6 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_setup_services( - hass: HomeAssistant, - calendar_service: GoogleCalendarService, -) -> None: - """Set up the service listeners.""" - - calendars = await hass.async_add_executor_job( - load_config, hass.config.path(YAML_DEVICES) - ) - calendars_file_lock = asyncio.Lock() - - async def _found_calendar(calendar_item: Calendar) -> None: - calendar = get_calendar_info( - hass, - calendar_item.dict(exclude_unset=True), - ) - calendar_id = calendar_item.id - # If the google_calendars.yaml file already exists, populate it for - # backwards compatibility, but otherwise do not create it if it does - # not exist. - if calendars: - if calendar_id not in calendars: - calendars[calendar_id] = calendar - async with calendars_file_lock: - await hass.async_add_executor_job( - update_config, hass.config.path(YAML_DEVICES), calendar - ) - else: - # Prefer entity/name information from yaml, overriding api - calendar = calendars[calendar_id] - async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar) - - created_calendars = set() - - async def _scan_for_calendars(call: ServiceCall) -> None: - """Scan for new calendars.""" - try: - result = await calendar_service.async_list_calendars() - except ApiException as err: - raise HomeAssistantError(str(err)) from err - tasks = [] - for calendar_item in result.items: - if calendar_item.id in created_calendars: - continue - created_calendars.add(calendar_item.id) - tasks.append(_found_calendar(calendar_item)) - await asyncio.gather(*tasks) - - hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) - - async def async_setup_add_event_service( hass: HomeAssistant, calendar_service: GoogleCalendarService, @@ -337,6 +303,12 @@ async def async_setup_add_event_service( async def _add_event(call: ServiceCall) -> None: """Add a new event to calendar.""" + _LOGGER.warning( + "The Google Calendar add_event service has been deprecated, and " + "will be removed in a future Home Assistant release. Please move " + "calls to the create_event service" + ) + start: DateOrDatetime | None = None end: DateOrDatetime | None = None @@ -359,11 +331,11 @@ async def async_setup_add_event_service( start = DateOrDatetime(date=start_in) end = DateOrDatetime(date=end_in) - elif EVENT_START_DATE in call.data: + elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data: start = DateOrDatetime(date=call.data[EVENT_START_DATE]) end = DateOrDatetime(date=call.data[EVENT_END_DATE]) - elif EVENT_START_DATETIME in call.data: + elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data: start_dt = call.data[EVENT_START_DATETIME] end_dt = call.data[EVENT_END_DATETIME] start = DateOrDatetime( diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index a4cda1ff41a..47aa32dcd11 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable import datetime import logging from typing import Any, cast @@ -19,9 +18,12 @@ from oauth2client.client import ( from homeassistant.components.application_credentials import AuthImplementation from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import ( + async_track_point_in_utc_time, + async_track_time_interval, +) from homeassistant.util import dt from .const import ( @@ -76,6 +78,9 @@ class DeviceFlow: self._oauth_flow = oauth_flow self._device_flow_info: DeviceFlowInfo = device_flow_info self._exchange_task_unsub: CALLBACK_TYPE | None = None + self._timeout_unsub: CALLBACK_TYPE | None = None + self._listener: CALLBACK_TYPE | None = None + self._creds: Credentials | None = None @property def verification_url(self) -> str: @@ -87,15 +92,22 @@ class DeviceFlow: """Return the code that the user should enter at the verification url.""" return self._device_flow_info.user_code # type: ignore[no-any-return] - async def start_exchange_task( - self, finished_cb: Callable[[Credentials | None], Awaitable[None]] + @callback + def async_set_listener( + self, + update_callback: CALLBACK_TYPE, ) -> None: - """Start the device auth exchange flow polling. + """Invoke the update callback when the exchange finishes or on timeout.""" + self._listener = update_callback - The callback is invoked with the valid credentials or with None on timeout. - """ + @property + def creds(self) -> Credentials | None: + """Return result of exchange step or None on timeout.""" + return self._creds + + def async_start_exchange(self) -> None: + """Start the device auth exchange flow polling.""" _LOGGER.debug("Starting exchange flow") - assert not self._exchange_task_unsub max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS) # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime # object without tzinfo. For the comparison below to work, it needs one. @@ -104,31 +116,40 @@ class DeviceFlow: ) expiration_time = min(user_code_expiry, max_timeout) - def _exchange() -> Credentials: - return self._oauth_flow.step2_exchange( - device_flow_info=self._device_flow_info - ) - - async def _poll_attempt(now: datetime.datetime) -> None: - assert self._exchange_task_unsub - _LOGGER.debug("Attempting OAuth code exchange") - # Note: The callback is invoked with None when the device code has expired - creds: Credentials | None = None - if now < expiration_time: - try: - creds = await self._hass.async_add_executor_job(_exchange) - except FlowExchangeError: - _LOGGER.debug("Token not yet ready; trying again later") - return - self._exchange_task_unsub() - self._exchange_task_unsub = None - await finished_cb(creds) - self._exchange_task_unsub = async_track_time_interval( self._hass, - _poll_attempt, + self._async_poll_attempt, datetime.timedelta(seconds=self._device_flow_info.interval), ) + self._timeout_unsub = async_track_point_in_utc_time( + self._hass, self._async_timeout, expiration_time + ) + + async def _async_poll_attempt(self, now: datetime.datetime) -> None: + _LOGGER.debug("Attempting OAuth code exchange") + try: + self._creds = await self._hass.async_add_executor_job(self._exchange) + except FlowExchangeError: + _LOGGER.debug("Token not yet ready; trying again later") + return + self._finish() + + def _exchange(self) -> Credentials: + return self._oauth_flow.step2_exchange(device_flow_info=self._device_flow_info) + + @callback + def _async_timeout(self, now: datetime.datetime) -> None: + _LOGGER.debug("OAuth token exchange timeout") + self._finish() + + @callback + def _finish(self) -> None: + if self._exchange_task_unsub: + self._exchange_task_unsub() + if self._timeout_unsub: + self._timeout_unsub() + if self._listener: + self._listener() def get_feature_access( @@ -173,7 +194,7 @@ async def async_create_device_flow( return DeviceFlow(hass, oauth_flow, device_flow_info) -class ApiAuthImpl(AbstractAuth): # type: ignore[misc] +class ApiAuthImpl(AbstractAuth): """Authentication implementation for google calendar api library.""" def __init__( @@ -191,7 +212,7 @@ class ApiAuthImpl(AbstractAuth): # type: ignore[misc] return cast(str, self._session.token["access_token"]) -class AccessTokenAuthImpl(AbstractAuth): # type: ignore[misc] +class AccessTokenAuthImpl(AbstractAuth): """Authentication implementation used during config flow, without refresh. This exists to allow the config flow to use the API before it has fully diff --git a/homeassistant/components/google/application_credentials.py b/homeassistant/components/google/application_credentials.py index 2f1fcba8084..3d557630b05 100644 --- a/homeassistant/components/google/application_credentials.py +++ b/homeassistant/components/google/application_credentials.py @@ -21,3 +21,12 @@ async def async_get_auth_implementation( ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: """Return auth implementation.""" return DeviceAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index ba4368fefae..3c271a2c3c3 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -9,7 +9,8 @@ from typing import Any from gcal_sync.api import GoogleCalendarService, ListEventsRequest from gcal_sync.exceptions import ApiException -from gcal_sync.model import Event +from gcal_sync.model import DateOrDatetime, Event +import voluptuous as vol from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, @@ -20,105 +21,197 @@ from homeassistant.components.calendar import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle from . import ( - CONF_CAL_ID, CONF_IGNORE_AVAILABILITY, CONF_SEARCH, CONF_TRACK, DATA_SERVICE, DEFAULT_CONF_OFFSET, DOMAIN, - SERVICE_SCAN_CALENDARS, + YAML_DEVICES, + get_calendar_info, + load_config, + update_config, +) +from .api import get_feature_access +from .const import ( + EVENT_DESCRIPTION, + EVENT_END_DATE, + EVENT_END_DATETIME, + EVENT_IN, + EVENT_IN_DAYS, + EVENT_IN_WEEKS, + EVENT_START_DATE, + EVENT_START_DATETIME, + EVENT_SUMMARY, + EVENT_TYPES_CONF, + FeatureAccess, ) -from .const import DISCOVER_CALENDAR _LOGGER = logging.getLogger(__name__) -DEFAULT_GOOGLE_SEARCH_PARAMS = { - "orderBy": "startTime", - "singleEvents": True, -} - MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) # Events have a transparency that determine whether or not they block time on calendar. # When an event is opaque, it means "Show me as busy" which is the default. Events that # are not opaque are ignored by default. -TRANSPARENCY = "transparency" OPAQUE = "opaque" +_EVENT_IN_TYPES = vol.Schema( + { + vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int, + vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int, + } +) + +SERVICE_CREATE_EVENT = "create_event" +CREATE_EVENT_SCHEMA = vol.All( + cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.make_entity_service_schema( + { + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Inclusive( + EVENT_START_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_END_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_START_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Inclusive( + EVENT_END_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Optional(EVENT_IN): _EVENT_IN_TYPES, + } + ), +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the google calendar platform.""" - - @callback - def async_discover(discovery_info: dict[str, Any]) -> None: - _async_setup_entities( - hass, - entry, - async_add_entities, - discovery_info, - ) - - entry.async_on_unload( - async_dispatcher_connect(hass, DISCOVER_CALENDAR, async_discover) - ) - - # Look for any new calendars + calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE] try: - await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, blocking=True) - except HomeAssistantError as err: - # This can happen if there's a connection error during setup. + result = await calendar_service.async_list_calendars() + except ApiException as err: raise PlatformNotReady(str(err)) from err + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + entity_entry_map = { + entity_entry.unique_id: entity_entry for entity_entry in registry_entries + } -@callback -def _async_setup_entities( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - disc_info: dict[str, Any], -) -> None: - calendar_service = hass.data[DOMAIN][DATA_SERVICE] + # Yaml configuration may override objects from the API + calendars = await hass.async_add_executor_job( + load_config, hass.config.path(YAML_DEVICES) + ) + new_calendars = [] entities = [] - num_entities = len(disc_info[CONF_ENTITIES]) - for data in disc_info[CONF_ENTITIES]: - entity_enabled = data.get(CONF_TRACK, True) - if not entity_enabled: - _LOGGER.warning( - "The 'track' option in google_calendars.yaml has been deprecated. The setting " - "has been imported to the UI, and should now be removed from google_calendars.yaml" - ) - entity_name = data[CONF_DEVICE_ID] - entity_id = generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass) - calendar_id = disc_info[CONF_CAL_ID] - if num_entities > 1: - # The google_calendars.yaml file lets users add multiple entities for - # the same calendar id and needs additional disambiguation - unique_id = f"{calendar_id}-{entity_name}" + for calendar_item in result.items: + calendar_id = calendar_item.id + if calendars and calendar_id in calendars: + calendar_info = calendars[calendar_id] else: - unique_id = calendar_id - entity = GoogleCalendarEntity( - calendar_service, - disc_info[CONF_CAL_ID], - data, - entity_id, - unique_id, - entity_enabled, - ) - entities.append(entity) + calendar_info = get_calendar_info( + hass, calendar_item.dict(exclude_unset=True) + ) + new_calendars.append(calendar_info) + # Yaml calendar config may map one calendar to multiple entities with extra options like + # offsets or search criteria. + num_entities = len(calendar_info[CONF_ENTITIES]) + for data in calendar_info[CONF_ENTITIES]: + entity_enabled = data.get(CONF_TRACK, True) + if not entity_enabled: + _LOGGER.warning( + "The 'track' option in google_calendars.yaml has been deprecated. The setting " + "has been imported to the UI, and should now be removed from google_calendars.yaml" + ) + entity_name = data[CONF_DEVICE_ID] + # The unique id is based on the config entry and calendar id since multiple accounts + # can have a common calendar id (e.g. `en.usa#holiday@group.v.calendar.google.com`). + # When using google_calendars.yaml with multiple entities for a single calendar, we + # have no way to set a unique id. + if num_entities > 1: + unique_id = None + else: + unique_id = f"{config_entry.unique_id}-{calendar_id}" + # Migrate to new unique_id format which supports multiple config entries as of 2022.7 + for old_unique_id in (calendar_id, f"{calendar_id}-{entity_name}"): + if not (entity_entry := entity_entry_map.get(old_unique_id)): + continue + if unique_id: + _LOGGER.debug( + "Migrating unique_id for %s from %s to %s", + entity_entry.entity_id, + old_unique_id, + unique_id, + ) + entity_registry.async_update_entity( + entity_entry.entity_id, new_unique_id=unique_id + ) + else: + _LOGGER.debug( + "Removing entity registry entry for %s from %s", + entity_entry.entity_id, + old_unique_id, + ) + entity_registry.async_remove( + entity_entry.entity_id, + ) + entities.append( + GoogleCalendarEntity( + calendar_service, + calendar_id, + data, + generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass), + unique_id, + entity_enabled, + ) + ) async_add_entities(entities, True) + if calendars and new_calendars: + + def append_calendars_to_config() -> None: + path = hass.config.path(YAML_DEVICES) + for calendar in new_calendars: + update_config(path, calendar) + + await hass.async_add_executor_job(append_calendars_to_config) + + platform = entity_platform.async_get_current_platform() + if get_feature_access(hass, config_entry) is FeatureAccess.read_write: + platform.async_register_entity_service( + SERVICE_CREATE_EVENT, + CREATE_EVENT_SCHEMA, + async_create_event, + ) + class GoogleCalendarEntity(CalendarEntity): """A calendar event device.""" @@ -129,12 +222,12 @@ class GoogleCalendarEntity(CalendarEntity): calendar_id: str, data: dict[str, Any], entity_id: str, - unique_id: str, + unique_id: str | None, entity_enabled: bool, ) -> None: """Create the Calendar event device.""" - self._calendar_service = calendar_service - self._calendar_id = calendar_id + self.calendar_service = calendar_service + self.calendar_id = calendar_id self._search: str | None = data.get(CONF_SEARCH) self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False) self._event: CalendarEvent | None = None @@ -173,7 +266,7 @@ class GoogleCalendarEntity(CalendarEntity): """Return True if the event is visible.""" if self._ignore_availability: return True - return event.transparency == OPAQUE # type: ignore[no-any-return] + return event.transparency == OPAQUE async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -181,14 +274,14 @@ class GoogleCalendarEntity(CalendarEntity): """Get all events in a specific time frame.""" request = ListEventsRequest( - calendar_id=self._calendar_id, + calendar_id=self.calendar_id, start_time=start_date, end_time=end_date, search=self._search, ) result_items = [] try: - result = await self._calendar_service.async_list_events(request) + result = await self.calendar_service.async_list_events(request) async for result_page in result: result_items.extend(result_page.items) except ApiException as err: @@ -202,9 +295,9 @@ class GoogleCalendarEntity(CalendarEntity): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Get the latest data.""" - request = ListEventsRequest(calendar_id=self._calendar_id, search=self._search) + request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search) try: - result = await self._calendar_service.async_list_events(request) + result = await self.calendar_service.async_list_events(request) except ApiException as err: _LOGGER.error("Unable to connect to Google: %s", err) return @@ -229,3 +322,52 @@ def _get_calendar_event(event: Event) -> CalendarEvent: description=event.description, location=event.location, ) + + +async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> None: + """Add a new event to calendar.""" + start: DateOrDatetime | None = None + end: DateOrDatetime | None = None + hass = entity.hass + + if EVENT_IN in call.data: + if EVENT_IN_DAYS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) + end_in = start_in + timedelta(days=1) + + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) + + elif EVENT_IN_WEEKS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) + end_in = start_in + timedelta(days=1) + + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) + + elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data: + start = DateOrDatetime(date=call.data[EVENT_START_DATE]) + end = DateOrDatetime(date=call.data[EVENT_END_DATE]) + + elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data: + start_dt = call.data[EVENT_START_DATETIME] + end_dt = call.data[EVENT_END_DATETIME] + start = DateOrDatetime(date_time=start_dt, timezone=str(hass.config.time_zone)) + end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone)) + + if start is None or end is None: + raise ValueError("Missing required fields to set start or end date/datetime") + + await entity.calendar_service.async_create_event( + entity.calendar_id, + Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ), + ) diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index be516230d2b..22b62094e76 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -1,12 +1,12 @@ """Config flow for Google integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from gcal_sync.api import GoogleCalendarService from gcal_sync.exceptions import ApiException -from oauth2client.client import Credentials import voluptuous as vol from homeassistant import config_entries @@ -59,14 +59,6 @@ class OAuth2FlowHandler( self.external_data = info return await super().async_step_creation(info) - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle external yaml configuration.""" - if not self._reauth_config_entry and self._async_current_entries(): - return self.async_abort(reason="already_configured") - return await super().async_step_user(user_input) - async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -104,9 +96,9 @@ class OAuth2FlowHandler( return self.async_abort(reason="oauth_error") self._device_flow = device_flow - async def _exchange_finished(creds: Credentials | None) -> None: + def _exchange_finished() -> None: self.external_data = { - DEVICE_AUTH_CREDS: creds + DEVICE_AUTH_CREDS: device_flow.creds } # is None on timeout/expiration self.hass.async_create_task( self.hass.config_entries.flow.async_configure( @@ -114,7 +106,8 @@ class OAuth2FlowHandler( ) ) - await device_flow.start_exchange_task(_exchange_finished) + device_flow.async_set_listener(_exchange_finished) + device_flow.async_start_exchange() return self.async_show_progress( step_id="auth", @@ -135,14 +128,14 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow, or update existing entry.""" - existing_entries = self._async_current_entries() - if existing_entries: - assert len(existing_entries) == 1 - entry = existing_entries[0] - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_reload(entry.entry_id) + if self._reauth_config_entry: + self.hass.config_entries.async_update_entry( + self._reauth_config_entry, data=data + ) + await self.hass.config_entries.async_reload( + self._reauth_config_entry.entry_id + ) return self.async_abort(reason="reauth_successful") - calendar_service = GoogleCalendarService( AccessTokenAuthImpl( async_get_clientsession(self.hass), data["token"]["access_token"] @@ -151,20 +144,19 @@ class OAuth2FlowHandler( try: primary_calendar = await calendar_service.async_get_calendar("primary") except ApiException as err: - _LOGGER.debug("Error reading calendar primary calendar: %s", err) - primary_calendar = None - title = primary_calendar.id if primary_calendar else self.flow_impl.name + _LOGGER.error("Error reading primary calendar: %s", err) + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(primary_calendar.id) + self._abort_if_unique_id_configured() return self.async_create_entry( - title=title, + title=primary_calendar.id, data=data, options={ CONF_CALENDAR_ACCESS: get_feature_access(self.hass).name, }, ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index c01ff1ea48b..f07958c2e6e 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -11,8 +11,6 @@ DATA_CALENDARS = "calendars" DATA_SERVICE = "service" DATA_CONFIG = "config" -DISCOVER_CALENDAR = "google_discover_calendar" - class FeatureAccess(Enum): """Class to represent different access scopes.""" @@ -31,3 +29,15 @@ class FeatureAccess(Enum): DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write + + +EVENT_DESCRIPTION = "description" +EVENT_END_DATE = "end_date" +EVENT_END_DATETIME = "end_date_time" +EVENT_IN = "in" +EVENT_IN_DAYS = "days" +EVENT_IN_WEEKS = "weeks" +EVENT_START_DATE = "start_date" +EVENT_START_DATETIME = "start_date_time" +EVENT_SUMMARY = "summary" +EVENT_TYPES_CONF = "event_types" diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 081eae34a95..d39f2093cf0 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/calendar.google/", - "requirements": ["gcal-sync==0.9.0", "oauth2client==4.1.3"], + "requirements": ["gcal-sync==0.10.0", "oauth2client==4.1.3"], "codeowners": ["@allenporter"], "iot_class": "cloud_polling", "loggers": ["googleapiclient"] diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index 21df763374f..a303ad7e18d 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -1,6 +1,3 @@ -scan_for_calendars: - name: Scan for calendars - description: Scan for new calendars. add_event: name: Add event description: Add a new calendar event. @@ -55,3 +52,54 @@ add_event: example: '"days": 2 or "weeks": 2' selector: object: +create_event: + name: Create event + description: Add a new calendar event. + target: + entity: + integration: google + domain: calendar + fields: + summary: + name: Summary + description: Acts as the title of the event. + required: true + example: "Bowling" + selector: + text: + description: + name: Description + description: The description of the event. Optional. + example: "Birthday bowling" + selector: + text: + start_date_time: + name: Start time + description: The date and time the event should start. + example: "2022-03-22 20:00:00" + selector: + text: + end_date_time: + name: End time + description: The date and time the event should end. + example: "2022-03-22 22:00:00" + selector: + text: + start_date: + name: Start date + description: The date the whole day event should start. + example: "2022-03-10" + selector: + text: + end_date: + name: End date + description: The date the whole day event should end. + example: "2022-03-11" + selector: + text: + in: + name: In + description: Days or weeks that you want to create the event in. + example: '"days": 2 or "weeks": 2' + selector: + object: diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index e32223627be..3ff75047f70 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -15,6 +15,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "code_expired": "Authentication code expired or credential setup is invalid, please try again.", @@ -36,5 +37,8 @@ } } } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" } } diff --git a/homeassistant/components/google/translations/bg.json b/homeassistant/components/google/translations/bg.json index 38b08fc3616..cd81f011d5a 100644 --- a/homeassistant/components/google/translations/bg.json +++ b/homeassistant/components/google/translations/bg.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430.", "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, @@ -16,5 +17,14 @@ "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" } } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "\u0414\u043e\u0441\u0442\u044a\u043f \u043d\u0430 Home Assistant \u0434\u043e Google \u041a\u0430\u043b\u0435\u043d\u0434\u0430\u0440" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/google/translations/ca.json b/homeassistant/components/google/translations/ca.json index 2c9190e4bfd..004fcb67f46 100644 --- a/homeassistant/components/google/translations/ca.json +++ b/homeassistant/components/google/translations/ca.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Segueix les [instruccions]({more_info_url}) de [la pantalla de consentiment OAuth]({oauth_consent_url}) perqu\u00e8 Home Assistant tingui acc\u00e9s al teu calendari de Google. Tamb\u00e9 has de crear les credencials d'aplicaci\u00f3 enlla\u00e7ades al calendari:\n 1. V\u00e9s a [Credencials]({oauth_creds_url}) i fes clic a **Crear credencials**.\n 2 A la llista desplegable, selecciona **ID de client OAuth**.\n 3. Selecciona **Dispositius TV i d'entrada limitada** al tipus d'aplicaci\u00f3.\n \n " + }, "config": { "abort": { "already_configured": "El compte ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "cannot_connect": "Ha fallat la connexi\u00f3", "code_expired": "El codi d'autenticaci\u00f3 ha caducat o la configuraci\u00f3 de credencials no \u00e9s v\u00e0lida. Torna-ho a provar.", "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", diff --git a/homeassistant/components/google/translations/de.json b/homeassistant/components/google/translations/de.json index c75cc90eb13..433324147dd 100644 --- a/homeassistant/components/google/translations/de.json +++ b/homeassistant/components/google/translations/de.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Folge den [Anweisungen]({more_info_url}) f\u00fcr den [OAuth-Zustimmungsbildschirm]({oauth_consent_url}), um Home Assistant Zugriff auf deinen Google-Kalender zu geben. Du musst auch Anwendungsnachweise erstellen, die mit deinem Kalender verkn\u00fcpft sind:\n1. Gehe zu [Credentials]({oauth_creds_url}) und klicke auf **Create Credentials**.\n1. W\u00e4hle in der Dropdown-Liste **OAuth-Client-ID**.\n1. W\u00e4hle **TV und eingeschr\u00e4nkte Eingabeger\u00e4te** f\u00fcr den Anwendungstyp.\n\n" + }, "config": { "abort": { "already_configured": "Konto wurde bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "cannot_connect": "Verbindung fehlgeschlagen", "code_expired": "Der Authentifizierungscode ist abgelaufen oder die Anmeldedaten sind ung\u00fcltig, bitte versuche es erneut.", "invalid_access_token": "Ung\u00fcltiger Zugriffs-Token", "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", diff --git a/homeassistant/components/google/translations/el.json b/homeassistant/components/google/translations/el.json index 11c78f96a93..65cc5a0038d 100644 --- a/homeassistant/components/google/translations/el.json +++ b/homeassistant/components/google/translations/el.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "\u0391\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b9\u03c2 [\u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2]({more_info_url}) \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd [\u03bf\u03b8\u03cc\u03bd\u03b7 \u03c3\u03c5\u03bd\u03b1\u03af\u03bd\u03b5\u03c3\u03b7\u03c2 OAuth]({oauth_consent_url}) \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03ce\u03c3\u03b5\u03c4\u03b5 \u03c3\u03c4\u03bf\u03bd \u0392\u03bf\u03b7\u03b8\u03cc Home \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7 \u03c3\u03c4\u03bf \u0397\u03bc\u03b5\u03c1\u03bf\u03bb\u03cc\u03b3\u03b9\u03bf Google \u03c3\u03b1\u03c2. \u03a0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03b5\u03c0\u03af\u03c3\u03b7\u03c2 \u03bd\u03b1 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03b5\u03c4\u03b5 \u0394\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1 \u0395\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b4\u03b5\u03bc\u03ad\u03bd\u03b1 \u03bc\u03b5 \u03c4\u03bf \u0397\u03bc\u03b5\u03c1\u03bf\u03bb\u03cc\u03b3\u03b9\u03cc \u03c3\u03b1\u03c2:\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b1 [\u0394\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1] ({oauth_creds_url}) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03b7\u03c1\u03af\u03c9\u03bd**.\n 1. \u0391\u03c0\u03cc \u03c4\u03b7\u03bd \u03b1\u03bd\u03b1\u03c0\u03c4\u03c5\u03c3\u03c3\u03cc\u03bc\u03b5\u03bd\u03b7 \u03bb\u03af\u03c3\u03c4\u03b1 \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **OAuth \u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7**.\n 1. \u0395\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **\u03a4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7 \u03ba\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 \u03c0\u03b5\u03c1\u03b9\u03bf\u03c1\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7\u03c2 \u03b5\u03b9\u03c3\u03cc\u03b4\u03bf\u03c5** \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03a4\u03cd\u03c0\u03bf \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2. \n\n" + }, "config": { "abort": { "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", "already_in_progress": "\u0397 \u03c1\u03bf\u03ae \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7\u03c2 \u03b2\u03c1\u03af\u03c3\u03ba\u03b5\u03c4\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03c3\u03b5 \u03b5\u03be\u03ad\u03bb\u03b9\u03be\u03b7", + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", "code_expired": "\u039f \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ad\u03bb\u03b7\u03be\u03b5 \u03ae \u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03c9\u03bd \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03b7\u03c1\u03af\u03c9\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03ac\u03ba\u03c5\u03c1\u03b7, \u03b4\u03bf\u03ba\u03b9\u03bc\u03ac\u03c3\u03c4\u03b5 \u03be\u03b1\u03bd\u03ac.", "invalid_access_token": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03b4\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", "missing_configuration": "\u03a4\u03bf \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03bf \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7.", @@ -27,5 +31,14 @@ "title": "\u0395\u03c0\u03b1\u03bd\u03b1\u03bb\u03b7\u03c0\u03c4\u03b9\u03ba\u03cc\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7\u03c2" } } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "\u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7 \u03c4\u03bf\u03c5 Home Assistant \u03c3\u03c4\u03bf \u0397\u03bc\u03b5\u03c1\u03bf\u03bb\u03cc\u03b3\u03b9\u03bf Google" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/google/translations/en.json b/homeassistant/components/google/translations/en.json index 58c89834ca5..2ef34ccc84b 100644 --- a/homeassistant/components/google/translations/en.json +++ b/homeassistant/components/google/translations/en.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" + }, "config": { "abort": { "already_configured": "Account is already configured", "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", "code_expired": "Authentication code expired or credential setup is invalid, please try again.", "invalid_access_token": "Invalid access token", "missing_configuration": "The component is not configured. Please follow the documentation.", diff --git a/homeassistant/components/google/translations/es.json b/homeassistant/components/google/translations/es.json index c6d990c2caa..9e777e6b377 100644 --- a/homeassistant/components/google/translations/es.json +++ b/homeassistant/components/google/translations/es.json @@ -1,6 +1,13 @@ { + "application_credentials": { + "description": "Sigue las [instrucciones]({more_info_url}) para la [pantalla de consentimiento de OAuth]({oauth_consent_url}) para dar acceso a Home Assistant a tu Google Calendar. Tambi\u00e9n necesita crear credenciales de aplicaci\u00f3n vinculadas a su calendario:\n1. Vaya a [Credenciales]({oauth_creds_url}) y haga clic en **Crear credenciales**.\n1. En la lista desplegable, seleccione **ID de cliente de OAuth**.\n1. Seleccione **TV y dispositivos de entrada limitada** para el tipo de aplicaci\u00f3n." + }, "config": { "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "already_in_progress": "El proceso de configuraci\u00f3n ya est\u00e1 en curso", + "code_expired": "El c\u00f3digo de autenticaci\u00f3n caduc\u00f3 o la configuraci\u00f3n de la credencial no es v\u00e1lida, int\u00e9ntelo de nuevo.", + "invalid_access_token": "Token de acceso no v\u00e1lido", "missing_configuration": "El componente no est\u00e1 configurado. Por favor, sigue la documentaci\u00f3n.", "oauth_error": "Se han recibido datos token inv\u00e1lidos.", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" @@ -8,12 +15,28 @@ "create_entry": { "default": "Autenticaci\u00f3n exitosa" }, + "progress": { + "exchange": "Para vincular su cuenta de Google, visite [ {url} ]( {url} ) e ingrese el c\u00f3digo: \n\n {user_code}" + }, "step": { "auth": { "title": "Vincular cuenta de Google" }, "pick_implementation": { "title": "Selecciona el m\u00e9todo de autenticaci\u00f3n" + }, + "reauth_confirm": { + "description": "La integraci\u00f3n de Google Calendar necesita volver a autenticar su cuenta", + "title": "Integraci\u00f3n de la reautenticaci\u00f3n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "Acceso de Home Assistant a Google Calendar" + } } } } diff --git a/homeassistant/components/google/translations/et.json b/homeassistant/components/google/translations/et.json index a115378f3a2..1b5aff5774b 100644 --- a/homeassistant/components/google/translations/et.json +++ b/homeassistant/components/google/translations/et.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "J\u00e4rgi [OAuth n\u00f5usoleku kuva]({oauth_consent_url}) [OAuth n\u00f5usoleku kuva] ) [juhiseid]({more_info_url}), et anda koduabilisele juurdep\u00e4\u00e4s oma Google'i kalendrile. Samuti pead looma kalendriga lingitud rakenduse identimisteabe.\n1. Mine aadressile [Mandaat]({oauth_creds_url}) ja kl\u00f5psake nuppu **Loo mandaat**.\n2. Vali ripploendist **OAuth kliendi ID**.\n3. Vali rakenduse t\u00fc\u00fcbi jaoks **TV ja piiratud sisendiga seadmed**.\n\n" + }, "config": { "abort": { "already_configured": "Kasutaja on juba seadistatud", "already_in_progress": "Seadistamine on juba k\u00e4imas", + "cannot_connect": "\u00dchendamine nurjus", "code_expired": "Tuvastuskood on aegunud v\u00f5i mandaadi seadistus on vale, proovi uuesti.", "invalid_access_token": "Vigane juurdep\u00e4\u00e4sut\u00f5end", "missing_configuration": "Komponent pole seadistatud. Palun loe dokumentatsiooni.", diff --git a/homeassistant/components/google/translations/fr.json b/homeassistant/components/google/translations/fr.json index 06903bdff07..7c5e4927787 100644 --- a/homeassistant/components/google/translations/fr.json +++ b/homeassistant/components/google/translations/fr.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Suivez les [instructions]({more_info_url}) de l'[\u00e9cran d'autorisation OAuth]({oauth_consent_url}) pour permettre \u00e0 Home Assistant d'acc\u00e9der \u00e0 votre agenda Google. Vous devez \u00e9galement cr\u00e9er des informations d'identification d'application li\u00e9es \u00e0 votre agenda\u00a0:\n1. Rendez-vous sur [Informations d'identification]({oauth_creds_url}) et cliquez sur **Cr\u00e9er des informations d'identification**.\n2. Dans la liste d\u00e9roulante, s\u00e9lectionnez **ID client OAuth**.\n3. S\u00e9lectionnez **TV et p\u00e9riph\u00e9riques d'entr\u00e9e limit\u00e9s** comme type d'application.\n\n" + }, "config": { "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", "code_expired": "Le code d'authentification a expir\u00e9 ou la configuration des informations d'identification n'est pas valide, veuillez r\u00e9essayer.", "invalid_access_token": "Jeton d'acc\u00e8s non valide", "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", diff --git a/homeassistant/components/google/translations/he.json b/homeassistant/components/google/translations/he.json index df5ec28163e..191450ed8eb 100644 --- a/homeassistant/components/google/translations/he.json +++ b/homeassistant/components/google/translations/he.json @@ -11,6 +11,9 @@ "create_entry": { "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" }, + "progress": { + "exchange": "\u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05d0\u05ea \u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d2\u05d5\u05d2\u05dc \u05e9\u05dc\u05da, \u05d9\u05e9 \u05dc\u05d1\u05e7\u05e8 \u05d1\u05db\u05ea\u05d5\u05d1\u05ea [{url}]({url}) \u05d5\u05dc\u05d4\u05d6\u05d9\u05df \u05e7\u05d5\u05d3:\n\n{user_code}" + }, "step": { "auth": { "title": "\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df \u05d2\u05d5\u05d2\u05dc" diff --git a/homeassistant/components/google/translations/hu.json b/homeassistant/components/google/translations/hu.json index 66ea71c72ec..0ff516dcfed 100644 --- a/homeassistant/components/google/translations/hu.json +++ b/homeassistant/components/google/translations/hu.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Az [OAuth beleegyez\u00e9si k\u00e9perny\u0151]({oauth_consent_url}) az [utas\u00edt\u00e1sok]({more_info_url}) k\u00f6vet\u00e9s\u00e9vel hozz\u00e1f\u00e9r\u00e9st biztos\u00edthat a Home Assistant-nak a Google Napt\u00e1rhoz. L\u00e9tre kell hoznia a napt\u00e1rhoz csatolt alkalmaz\u00e1s-hiteles\u00edt\u0151 adatokat is:\n1. Nyissa meg a [Credentials]({oauth_creds_url}) webhelyet, \u00e9s kattintson a **Hiteles\u00edt\u0151 adatok l\u00e9trehoz\u00e1sa**-ra.\n1. A leg\u00f6rd\u00fcl\u0151 list\u00e1b\u00f3l v\u00e1lassza a **OAuth \u00fcgyf\u00e9lazonos\u00edt\u00f3**-t.\n1. V\u00e1lassza a **TV \u00e9s a korl\u00e1tozott beviteli eszk\u00f6z\u00f6k** lehet\u0151s\u00e9get az alkalmaz\u00e1st\u00edpushoz." + }, "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", "code_expired": "A hiteles\u00edt\u00e9si k\u00f3d lej\u00e1rt vagy a hiteles\u00edt\u0151 adatok be\u00e1ll\u00edt\u00e1sa \u00e9rv\u00e9nytelen, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra.", "invalid_access_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", diff --git a/homeassistant/components/google/translations/id.json b/homeassistant/components/google/translations/id.json index 25c3e9fa1e6..ea13f27fce5 100644 --- a/homeassistant/components/google/translations/id.json +++ b/homeassistant/components/google/translations/id.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Ikuti [petunjuk]({more_info_url}) untuk [Layar persetujuan OAuth]({oauth_consent_url}) untuk memberi Home Assistant akses ke Google Kalender Anda. Anda juga perlu membuat Kredensial Aplikasi yang ditautkan ke Kalender Anda:\n1. Buka [Kredensial]({oauth_creds_url}) dan klik **Buat Kredensial**.\n1. Dari daftar pilihan pilih **ID klien OAuth **.\n1. Pilih **TV dan Perangkat Input Terbatas** untuk Jenis Aplikasi.\n\n" + }, "config": { "abort": { "already_configured": "Akun sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", + "cannot_connect": "Gagal terhubung", "code_expired": "Kode autentikasi kedaluwarsa atau penyiapan kredensial tidak valid, coba lagi.", "invalid_access_token": "Token akses tidak valid", "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", diff --git a/homeassistant/components/google/translations/it.json b/homeassistant/components/google/translations/it.json index d57998894d9..ef5ec01202d 100644 --- a/homeassistant/components/google/translations/it.json +++ b/homeassistant/components/google/translations/it.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Segui le [istruzioni]({more_info_url}) per la [Schermata di consenso OAuth]({oauth_consent_url}) per consentire ad Home Assistant di accedere al tuo Google Calendar. Devi anche creare le credenziali dell'applicazione collegate al tuo calendario:\n 1. Vai a [Credenziali]({oauth_creds_url}) e fai clic su **Crea credenziali**.\n 2. Dall'elenco a discesa selezionare **ID client OAuth**.\n 3. Selezionare **TV e dispositivi con ingresso limitato** come Tipo di applicazione. \n\n" + }, "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "cannot_connect": "Impossibile connettersi", "code_expired": "Il codice di autenticazione \u00e8 scaduto o la configurazione delle credenziali non \u00e8 valida, riprova.", "invalid_access_token": "Token di accesso non valido", "missing_configuration": "Il componente non \u00e8 configurato. Segui la documentazione.", diff --git a/homeassistant/components/google/translations/ja.json b/homeassistant/components/google/translations/ja.json index 854e7ba1961..4cb05958a76 100644 --- a/homeassistant/components/google/translations/ja.json +++ b/homeassistant/components/google/translations/ja.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "[OAuth\u540c\u610f\u753b\u9762] ({more_info_url}) \u306e\u624b\u9806 ({oauth_consent_url}) \u306b\u5f93\u3063\u3066\u3001Home Assistant\u304c\u3042\u306a\u305f\u306eGoogle\u30ab\u30ec\u30f3\u30c0\u30fc\u306b\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002\u307e\u305f\u3001\u30ab\u30ec\u30f3\u30c0\u30fc\u306b\u30ea\u30f3\u30af\u3057\u305f\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u8cc7\u683c\u60c5\u5831\u3092\u4f5c\u6210\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n 1. [\u8cc7\u683c\u60c5\u5831]({oauth_creds_url} \u306b\u79fb\u52d5\u3057\u3001**Create Credentials** \u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002\n 1. \u30c9\u30ed\u30c3\u30d7\u30c0\u30a6\u30f3\u30ea\u30b9\u30c8\u304b\u3089 **OAuth client ID** \u3092\u9078\u629e\u3057\u307e\u3059\u3002\n 1. \u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u30bf\u30a4\u30d7\u3068\u3057\u3066**TV\u304a\u3088\u3073\u5236\u9650\u4ed8\u304d\u5165\u529b\u30c7\u30d0\u30a4\u30b9** \u3092\u9078\u629e\u3057\u307e\u3059\u3002\n\n" + }, "config": { "abort": { "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", "code_expired": "\u8a8d\u8a3c\u30b3\u30fc\u30c9\u306e\u6709\u52b9\u671f\u9650\u304c\u5207\u308c\u3066\u3044\u308b\u304b\u3001\u8cc7\u683c\u60c5\u5831\u306e\u8a2d\u5b9a\u304c\u7121\u52b9\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", diff --git a/homeassistant/components/google/translations/nl.json b/homeassistant/components/google/translations/nl.json index 419259d66f8..2f8d67af1e2 100644 --- a/homeassistant/components/google/translations/nl.json +++ b/homeassistant/components/google/translations/nl.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Account is al geconfigureerd", "already_in_progress": "De configuratie is momenteel al bezig", + "cannot_connect": "Kan geen verbinding maken", "code_expired": "De authenticatiecode is verlopen of de instelling van de inloggegevens is ongeldig, probeer het opnieuw.", "invalid_access_token": "Ongeldig toegangstoken", "missing_configuration": "Integratie niet geconfigureerd. Raadpleeg de documentatie.", diff --git a/homeassistant/components/google/translations/no.json b/homeassistant/components/google/translations/no.json index ef65c7fe9a5..9842da0362c 100644 --- a/homeassistant/components/google/translations/no.json +++ b/homeassistant/components/google/translations/no.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "F\u00f8lg [instruksjonene]( {more_info_url} ) for [OAuth-samtykkeskjermen]( {oauth_consent_url} ) for \u00e5 gi Home Assistant tilgang til Google-kalenderen din. Du m\u00e5 ogs\u00e5 opprette applikasjonslegitimasjon knyttet til kalenderen din:\n 1. G\u00e5 til [Credentials]( {oauth_creds_url} ) og klikk p\u00e5 **Create Credentials**.\n 1. Velg **OAuth-klient-ID** fra rullegardinlisten.\n 1. Velg **TV og begrensede inngangsenheter** for applikasjonstype. " + }, "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "cannot_connect": "Tilkobling mislyktes", "code_expired": "Autentiseringskoden er utl\u00f8pt eller p\u00e5loggingsoppsettet er ugyldig. Pr\u00f8v p\u00e5 nytt.", "invalid_access_token": "Ugyldig tilgangstoken", "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", diff --git a/homeassistant/components/google/translations/pl.json b/homeassistant/components/google/translations/pl.json index fff2a20ee39..3f78e3e9d6f 100644 --- a/homeassistant/components/google/translations/pl.json +++ b/homeassistant/components/google/translations/pl.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Post\u0119puj zgodnie z [instrukcjami]( {more_info_url} ) na [ekran akceptacji OAuth]( {oauth_consent_url} ), aby przyzna\u0107 Home Assistantowi dost\u0119p do Twojego Kalendarza Google. Musisz r\u00f3wnie\u017c utworzy\u0107 po\u015bwiadczenia aplikacji po\u0142\u0105czone z Twoim kalendarzem:\n1. Przejd\u017a do [Po\u015bwiadczenia]( {oauth_creds_url} ) i kliknij **Utw\u00f3rz po\u015bwiadczenia**.\n2. Z listy rozwijanej wybierz **ID klienta OAuth**.\n3. Wybierz **TV i urz\u0105dzenia z ograniczonym wej\u015bciem** jako typ aplikacji.\n\n" + }, "config": { "abort": { "already_configured": "Konto jest ju\u017c skonfigurowane", "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "code_expired": "Kod uwierzytelniaj\u0105cy wygas\u0142 lub konfiguracja po\u015bwiadcze\u0144 jest nieprawid\u0142owa, spr\u00f3buj ponownie.", "invalid_access_token": "Niepoprawny token dost\u0119pu", "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", diff --git a/homeassistant/components/google/translations/pt-BR.json b/homeassistant/components/google/translations/pt-BR.json index 85c7254b9a7..e934155c9fa 100644 --- a/homeassistant/components/google/translations/pt-BR.json +++ b/homeassistant/components/google/translations/pt-BR.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Siga as [instru\u00e7\u00f5es]( {more_info_url} ) para [tela de consentimento da OAuth]( {oauth_consent_url} ) para conceder ao Home Assistant acesso ao seu Google Agenda. Voc\u00ea tamb\u00e9m precisa criar credenciais de aplicativo vinculadas ao seu calend\u00e1rio:\n 1. Acesse [Credentials]( {oauth_creds_url} ) e clique em **Create Credentials**.\n 1. Na lista suspensa, selecione **ID do cliente OAuth**.\n 1. Selecione **TV e dispositivos de entrada limitada** para o tipo de aplicativo. \n\n" + }, "config": { "abort": { "already_configured": "A conta j\u00e1 foi configurada", "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "cannot_connect": "Falha ao se conectar", "code_expired": "O c\u00f3digo de autentica\u00e7\u00e3o expirou ou a configura\u00e7\u00e3o da credencial \u00e9 inv\u00e1lida. Tente novamente.", "invalid_access_token": "Token de acesso inv\u00e1lido", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", diff --git a/homeassistant/components/google/translations/sv.json b/homeassistant/components/google/translations/sv.json new file mode 100644 index 00000000000..7f1f140af90 --- /dev/null +++ b/homeassistant/components/google/translations/sv.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats", + "cannot_connect": "Det gick inte att ansluta." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/tr.json b/homeassistant/components/google/translations/tr.json index f7b5b6d79ff..ec265926695 100644 --- a/homeassistant/components/google/translations/tr.json +++ b/homeassistant/components/google/translations/tr.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Home Assistant'\u0131n Google Takviminize eri\u015fmesine izin vermek i\u00e7in [OAuth izin ekran\u0131]( {oauth_consent_url} ) i\u00e7in [talimatlar\u0131]( {more_info_url} ) uygulay\u0131n. Ayr\u0131ca, Takviminize ba\u011fl\u0131 Uygulama Kimlik Bilgileri olu\u015fturman\u0131z gerekir:\n 1. [Kimlik Bilgileri]( {oauth_creds_url} ) \u00f6\u011fesine gidin ve **Kimlik Bilgileri Olu\u015ftur**'u t\u0131klay\u0131n.\n 1. A\u00e7\u0131l\u0131r listeden **OAuth istemci kimli\u011fi**'ni se\u00e7in.\n 1. Uygulama T\u00fcr\u00fc i\u00e7in **TV ve S\u0131n\u0131rl\u0131 Giri\u015f cihazlar\u0131**'n\u0131 se\u00e7in. \n\n" + }, "config": { "abort": { "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "cannot_connect": "Ba\u011flanma hatas\u0131", "code_expired": "Kimlik do\u011frulama kodunun s\u00fcresi doldu veya kimlik bilgisi kurulumu ge\u00e7ersiz, l\u00fctfen tekrar deneyin.", "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", diff --git a/homeassistant/components/google/translations/uk.json b/homeassistant/components/google/translations/uk.json new file mode 100644 index 00000000000..d0beb9cab9f --- /dev/null +++ b/homeassistant/components/google/translations/uk.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/zh-Hant.json b/homeassistant/components/google/translations/zh-Hant.json index 988c6629af7..bee271ea8e7 100644 --- a/homeassistant/components/google/translations/zh-Hant.json +++ b/homeassistant/components/google/translations/zh-Hant.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "\u8ddf\u96a8[\u8aaa\u660e]({more_info_url})\u4ee5\u8a2d\u5b9a\u81f3 [OAuth \u540c\u610f\u756b\u9762]({oauth_consent_url})\u3001\u4f9b Home Assistant \u5b58\u53d6\u60a8\u7684 Google \u65e5\u66c6\u3002\u540c\u6642\u9700\u8981\u65b0\u589e\u9023\u7d50\u81f3\u65e5\u66c6\u7684\u61c9\u7528\u7a0b\u5f0f\u6191\u8b49\uff1a\n1. \u700f\u89bd\u81f3 [\u6191\u8b49]({oauth_creds_url}) \u9801\u9762\u4e26\u9ede\u9078 **\u5efa\u7acb\u6191\u8b49**\u3002\n1. \u7531\u4e0b\u62c9\u9078\u55ae\u4e2d\u9078\u64c7 **OAuth \u7528\u6236\u7aef ID**\u3002\n1. \u61c9\u7528\u7a0b\u5f0f\u985e\u578b\u5247\u9078\u64c7 **\u96fb\u8996\u548c\u53d7\u9650\u5236\u7684\u8f38\u5165\u88dd\u7f6e**\u3002\n\n" + }, "config": { "abort": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "cannot_connect": "\u9023\u7dda\u5931\u6557", "code_expired": "\u8a8d\u8b49\u78bc\u5df2\u904e\u671f\u6216\u6191\u8b49\u8a2d\u5b9a\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index dca436d8e2a..638ccfd9133 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -5,9 +5,10 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( @@ -23,6 +24,7 @@ from .const import ( CONF_ROOM_HINT, CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, + DATA_CONFIG, DEFAULT_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSED_DOMAINS, DOMAIN, @@ -37,6 +39,8 @@ _LOGGER = logging.getLogger(__name__) CONF_ALLOW_UNLOCK = "allow_unlock" +PLATFORMS = [Platform.BUTTON] + ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME): cv.string, @@ -95,11 +99,48 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: if DOMAIN not in yaml_config: return True - config = yaml_config[DOMAIN] + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CONFIG] = yaml_config[DOMAIN] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_PROJECT_ID: yaml_config[DOMAIN][CONF_PROJECT_ID]}, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + config: ConfigType = {**hass.data[DOMAIN][DATA_CONFIG]} + + if entry.source == SOURCE_IMPORT: + # if project was changed, remove entry a new will be setup + if config[CONF_PROJECT_ID] != entry.data[CONF_PROJECT_ID]: + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + config.update(entry.data) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, config[CONF_PROJECT_ID])}, + manufacturer="Google", + model="Google Assistant", + name=config[CONF_PROJECT_ID], + entry_type=dr.DeviceEntryType.SERVICE, + ) google_config = GoogleConfig(hass, config) await google_config.async_initialize() + hass.data[DOMAIN][entry.entry_id] = google_config + hass.http.register_view(GoogleAssistantView(google_config)) if google_config.should_report_state: @@ -123,4 +164,6 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py new file mode 100644 index 00000000000..322a021053a --- /dev/null +++ b/homeassistant/components/google_assistant/button.py @@ -0,0 +1,53 @@ +"""Support for buttons.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_PROJECT_ID, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN +from .http import GoogleConfig + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the platform.""" + yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] + google_config: GoogleConfig = hass.data[DOMAIN][config_entry.entry_id] + + entities = [] + + if CONF_SERVICE_ACCOUNT in yaml_config: + entities.append(SyncButton(config_entry.data[CONF_PROJECT_ID], google_config)) + + async_add_entities(entities) + + +class SyncButton(ButtonEntity): + """Representation of a synchronization button.""" + + def __init__(self, project_id: str, google_config: GoogleConfig) -> None: + """Initialize button.""" + super().__init__() + self._google_config = google_config + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_unique_id = f"{project_id}_sync" + self._attr_name = "Synchronize Devices" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, project_id)}) + + async def async_press(self) -> None: + """Press the button.""" + assert self._context + agent_user_id = self._google_config.get_agent_user_id(self._context) + result = await self._google_config.async_sync_entities(agent_user_id) + if result != 200: + raise HomeAssistantError( + f"Unable to sync devices with result code: {result}, check log for more info." + ) diff --git a/homeassistant/components/google_assistant/config_flow.py b/homeassistant/components/google_assistant/config_flow.py new file mode 100644 index 00000000000..e8e0d9962f9 --- /dev/null +++ b/homeassistant/components/google_assistant/config_flow.py @@ -0,0 +1,19 @@ +"""Config flow for google assistant component.""" + +from homeassistant import config_entries + +from .const import CONF_PROJECT_ID, DOMAIN + + +class GoogleAssistantHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_import(self, user_input): + """Import a config entry.""" + await self.async_set_unique_id(unique_id=user_input[CONF_PROJECT_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_PROJECT_ID], data=user_input + ) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index a19707bffbc..dbcf60ac098 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -40,6 +40,8 @@ CONF_ROOM_HINT = "room" CONF_SECURE_DEVICES_PIN = "secure_devices_pin" CONF_SERVICE_ACCOUNT = "service_account" +DATA_CONFIG = "config" + DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ "alarm_control_panel", @@ -86,6 +88,7 @@ TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER" TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH" TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" TYPE_TV = f"{PREFIX_TYPES}TV" +TYPE_WINDOW = f"{PREFIX_TYPES}WINDOW" TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" SERVICE_REQUEST_SYNC = "request_sync" @@ -147,7 +150,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.DOOR): TYPE_DOOR, (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.LOCK): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.OPENING): TYPE_SENSOR, - (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.WINDOW): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.WINDOW): TYPE_WINDOW, ( binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, diff --git a/homeassistant/components/google_assistant/diagnostics.py b/homeassistant/components/google_assistant/diagnostics.py new file mode 100644 index 00000000000..01e17e0bcf8 --- /dev/null +++ b/homeassistant/components/google_assistant/diagnostics.py @@ -0,0 +1,41 @@ +"""Diagnostics support for Hue.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics.const import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN +from .http import GoogleConfig +from .smart_home import async_devices_sync_response, create_sync_response + +TO_REDACT = [ + "uuid", + "baseUrl", + "webhookId", + CONF_SERVICE_ACCOUNT, + CONF_SECURE_DEVICES_PIN, + CONF_API_KEY, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostic information.""" + data = hass.data[DOMAIN] + config: GoogleConfig = data[entry.entry_id] + yaml_config: ConfigType = data[DATA_CONFIG] + devices = await async_devices_sync_response(hass, config, REDACTED) + sync = create_sync_response(REDACTED, devices) + + return { + "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "yaml_config": async_redact_data(yaml_config, TO_REDACT), + "sync": async_redact_data(sync, TO_REDACT), + } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 15a8d832403..6f81ddebdb4 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -90,6 +90,7 @@ class AbstractConfig(ABC): self._local_sdk_active = False self._local_last_active: datetime | None = None self._local_sdk_version_warn = False + self.is_supported_cache: dict[str, tuple[int | None, bool]] = {} async def async_initialize(self): """Perform async initialization of config.""" @@ -159,7 +160,9 @@ class AbstractConfig(ABC): def get_local_webhook_id(self, agent_user_id): """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" - return self._store.agent_user_ids[agent_user_id][STORE_GOOGLE_LOCAL_WEBHOOK_ID] + if data := self._store.agent_user_ids.get(agent_user_id): + return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] + return None @abstractmethod def get_agent_user_id(self, context): @@ -356,9 +359,6 @@ class AbstractConfig(ABC): pprint.pformat(payload), ) - if not self.enabled: - return json_response(smart_home.turned_off_response(payload)) - if (agent_user_id := self.get_local_agent_user_id(webhook_id)) is None: # No agent user linked to this webhook, means that the user has somehow unregistered # removing webhook and stopping processing of this request. @@ -370,6 +370,11 @@ class AbstractConfig(ABC): webhook.async_unregister(self.hass, webhook_id) return None + if not self.enabled: + return json_response( + smart_home.api_disabled_response(payload, agent_user_id) + ) + result = await smart_home.async_handle_message( self.hass, self, @@ -539,7 +544,17 @@ class GoogleEntity: @callback def is_supported(self) -> bool: """Return if the entity is supported by Google.""" - return bool(self.traits()) + 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] @callback def might_2fa(self) -> bool: diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 805c9100d9f..75a3fd76b9b 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -71,6 +71,24 @@ async def _process(hass, data, message): return {"requestId": data.request_id, "payload": result} +async def async_devices_sync_response(hass, config, agent_user_id): + """Generate the device serialization.""" + entities = async_get_entities(hass, config) + instance_uuid = await instance_id.async_get(hass) + devices = [] + + for entity in entities: + if not entity.should_expose(): + continue + + try: + devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error serializing %s", entity.entity_id) + + return devices + + @HANDLERS.register("action.devices.SYNC") async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. @@ -86,20 +104,8 @@ async def async_devices_sync(hass, data, payload): agent_user_id = data.config.get_agent_user_id(data.context) await data.config.async_connect_agent_user(agent_user_id) - entities = async_get_entities(hass, data.config) - instance_uuid = await instance_id.async_get(hass) - devices = [] - - for entity in entities: - if not entity.should_expose(): - continue - - try: - devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error serializing %s", entity.entity_id) - - response = {"agentUserId": agent_user_id, "devices": devices} + devices = await async_devices_sync_response(hass, data.config, agent_user_id) + response = create_sync_response(agent_user_id, devices) _LOGGER.debug("Syncing entities response: %s", response) @@ -300,9 +306,24 @@ async def async_devices_proxy_selected(hass, data: RequestData, payload): return {} -def turned_off_response(message): +def create_sync_response(agent_user_id: str, devices: list): + """Return an empty sync response.""" + return { + "agentUserId": agent_user_id, + "devices": devices, + } + + +def api_disabled_response(message, agent_user_id): """Return a device turned off response.""" + inputs: list = message.get("inputs") + + if inputs and inputs[0].get("intent") == "action.devices.SYNC": + payload = create_sync_response(agent_user_id, []) + else: + payload = {"errorCode": "deviceTurnedOff"} + return { "requestId": message.get("requestId"), - "payload": {"errorCode": "deviceTurnedOff"}, + "payload": payload, } diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 87da1f55fca..633c5edc453 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "google_cloud", "name": "Google Cloud Platform", "documentation": "https://www.home-assistant.io/integrations/google_cloud", - "requirements": ["google-cloud-texttospeech==2.11.0"], + "requirements": ["google-cloud-texttospeech==2.11.1"], "codeowners": ["@lufton"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/google_travel_time/translations/sv.json b/homeassistant/components/google_travel_time/translations/sv.json index 18a9d3d507e..1b8cc14bce7 100644 --- a/homeassistant/components/google_travel_time/translations/sv.json +++ b/homeassistant/components/google_travel_time/translations/sv.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "api_key": "API-nyckel", "destination": "Destination", "origin": "Ursprung" } diff --git a/homeassistant/components/gree/manifest.json b/homeassistant/components/gree/manifest.json index 23a5c654abc..1b2c8dd6a2a 100644 --- a/homeassistant/components/gree/manifest.json +++ b/homeassistant/components/gree/manifest.json @@ -3,7 +3,7 @@ "name": "Gree Climate", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gree", - "requirements": ["greeclimate==1.1.1"], + "requirements": ["greeclimate==1.2.0"], "codeowners": ["@cmroche"], "iot_class": "local_polling", "loggers": ["greeclimate"] diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index e6d7e91f035..1f8fba21e78 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -104,6 +104,12 @@ CONFIG_SCHEMA = vol.Schema( ) +def _async_get_component(hass: HomeAssistant) -> EntityComponent: + if (component := hass.data.get(DOMAIN)) is None: + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) + return component + + class GroupIntegrationRegistry: """Class to hold a registry of integrations.""" @@ -274,7 +280,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_process_integration_platforms(hass, DOMAIN, _process_group_platform) - await _async_process_config(hass, config, component) + await _async_process_config(hass, config) async def reload_service_handler(service: ServiceCall) -> None: """Remove all user-defined groups and load new ones from config.""" @@ -286,7 +292,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := await component.async_prepare_reload()) is None: return - await _async_process_config(hass, conf, component) + await _async_process_config(hass, conf) await component.async_add_entities(auto) @@ -406,31 +412,33 @@ async def _process_group_platform(hass, domain, platform): platform.async_describe_on_off_states(hass, hass.data[REG_KEY]) -async def _async_process_config(hass, config, component): +async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None: """Process group configuration.""" hass.data.setdefault(GROUP_ORDER, 0) - tasks = [] + entities = [] + domain_config: dict[str, dict[str, Any]] = config.get(DOMAIN, {}) - for object_id, conf in config.get(DOMAIN, {}).items(): - name = conf.get(CONF_NAME, object_id) - entity_ids = conf.get(CONF_ENTITIES) or [] - icon = conf.get(CONF_ICON) - mode = conf.get(CONF_ALL) + for object_id, conf in domain_config.items(): + name: str = conf.get(CONF_NAME, object_id) + entity_ids: Iterable[str] = conf.get(CONF_ENTITIES) or [] + icon: str | None = conf.get(CONF_ICON) + mode = bool(conf.get(CONF_ALL)) + order: int = hass.data[GROUP_ORDER] # We keep track of the order when we are creating the tasks # in the same way that async_create_group does to make # sure we use the same ordering system. This overcomes # the problem with concurrently creating the groups - tasks.append( - Group.async_create_group( + entities.append( + Group.async_create_group_entity( hass, name, entity_ids, icon=icon, object_id=object_id, mode=mode, - order=hass.data[GROUP_ORDER], + order=order, ) ) @@ -439,7 +447,8 @@ async def _async_process_config(hass, config, component): # we setup a new group hass.data[GROUP_ORDER] += 1 - await asyncio.gather(*tasks) + # If called before the platform async_setup is called (test cases) + await _async_get_component(hass).async_add_entities(entities) class GroupEntity(Entity): @@ -478,14 +487,14 @@ class Group(Entity): def __init__( self, - hass, - name, - order=None, - icon=None, - user_defined=True, - entity_ids=None, - mode=None, - ): + hass: HomeAssistant, + name: str, + order: int | None = None, + icon: str | None = None, + user_defined: bool = True, + entity_ids: Iterable[str] | None = None, + mode: bool | None = None, + ) -> None: """Initialize a group. This Object has factory function for creation. @@ -508,15 +517,15 @@ class Group(Entity): @staticmethod def create_group( - hass, - name, - entity_ids=None, - user_defined=True, - icon=None, - object_id=None, - mode=None, - order=None, - ): + hass: HomeAssistant, + name: str, + entity_ids: Iterable[str] | None = None, + user_defined: bool = True, + icon: str | None = None, + object_id: str | None = None, + mode: bool | None = None, + order: int | None = None, + ) -> Group: """Initialize a group.""" return asyncio.run_coroutine_threadsafe( Group.async_create_group( @@ -526,20 +535,18 @@ class Group(Entity): ).result() @staticmethod - async def async_create_group( - hass, - name, - entity_ids=None, - user_defined=True, - icon=None, - object_id=None, - mode=None, - order=None, - ): - """Initialize a group. - - This method must be run in the event loop. - """ + @callback + def async_create_group_entity( + hass: HomeAssistant, + name: str, + entity_ids: Iterable[str] | None = None, + user_defined: bool = True, + icon: str | None = None, + object_id: str | None = None, + mode: bool | None = None, + order: int | None = None, + ) -> Group: + """Create a group entity.""" if order is None: hass.data.setdefault(GROUP_ORDER, 0) order = hass.data[GROUP_ORDER] @@ -562,12 +569,30 @@ class Group(Entity): ENTITY_ID_FORMAT, object_id or name, hass=hass ) + return group + + @staticmethod + @callback + async def async_create_group( + hass: HomeAssistant, + name: str, + entity_ids: Iterable[str] | None = None, + user_defined: bool = True, + icon: str | None = None, + object_id: str | None = None, + mode: bool | None = None, + order: int | None = None, + ) -> Group: + """Initialize a group. + + This method must be run in the event loop. + """ + group = Group.async_create_group_entity( + hass, name, entity_ids, user_defined, icon, object_id, mode, order + ) + # If called before the platform async_setup is called (test cases) - if (component := hass.data.get(DOMAIN)) is None: - component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) - - await component.async_add_entities([group]) - + await _async_get_component(hass).async_add_entities([group]) return group @property diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 54a98a68e43..473a5a5e885 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -88,6 +88,8 @@ async def async_setup_entry( class BinarySensorGroup(GroupEntity, BinarySensorEntity): """Representation of a BinarySensorGroup.""" + _attr_available: bool = False + def __init__( self, unique_id: str | None, @@ -127,27 +129,24 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): @callback def async_update_group_state(self) -> None: """Query all members and determine the binary sensor group state.""" - all_states = [self.hass.states.get(x) for x in self._entity_ids] + states = [ + state.state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] - # filtered_states are members currently in the state machine - filtered_states: list[str] = [x.state for x in all_states if x is not None] - - # Set group as unavailable if all members are unavailable - self._attr_available = any( - state != STATE_UNAVAILABLE for state in filtered_states - ) + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state != STATE_UNAVAILABLE for state in states) valid_state = self.mode( - state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) if not valid_state: # Set as unknown if any / all member is not unknown or unavailable self._attr_is_on = None else: # Set as ON if any / all member is ON - states = list(map(lambda x: x == STATE_ON, filtered_states)) - state = self.mode(states) - self._attr_is_on = state + self._attr_is_on = self.mode(state == STATE_ON for state in states) @property def device_class(self) -> str | None: diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index c2cd3c6e9d2..a867c92d956 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -35,6 +35,8 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -98,6 +100,7 @@ async def async_setup_entry( class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" + _attr_available: bool = False _attr_is_closed: bool | None = None _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False @@ -267,29 +270,38 @@ class CoverGroup(GroupEntity, CoverEntity): """Update state and attributes.""" self._attr_assumed_state = False + states = [ + state.state + for entity_id in self._entities + if (state := self.hass.states.get(entity_id)) is not None + ] + + valid_state = any( + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states + ) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state != STATE_UNAVAILABLE for state in states) + self._attr_is_closed = True self._attr_is_closing = False self._attr_is_opening = False - has_valid_state = False for entity_id in self._entities: if not (state := self.hass.states.get(entity_id)): continue if state.state == STATE_OPEN: self._attr_is_closed = False - has_valid_state = True continue if state.state == STATE_CLOSED: - has_valid_state = True continue if state.state == STATE_CLOSING: self._attr_is_closing = True - has_valid_state = True continue if state.state == STATE_OPENING: self._attr_is_opening = True - has_valid_state = True continue - if not has_valid_state: + if not valid_state: + # Set as unknown if all members are unknown or unavailable self._attr_is_closed = None position_covers = self._covers[KEY_POSITION] diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4badbe6df51..7d09c9573b5 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -32,6 +32,8 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -98,6 +100,7 @@ async def async_setup_entry( class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" + _attr_available: bool = False _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: @@ -109,7 +112,7 @@ class FanGroup(GroupEntity, FanEntity): self._direction = None self._supported_features = 0 self._speed_count = 100 - self._is_on = False + self._is_on: bool | None = False self._attr_name = name self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} self._attr_unique_id = unique_id @@ -125,7 +128,7 @@ class FanGroup(GroupEntity, FanEntity): return self._speed_count @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the entity is on.""" return self._is_on @@ -270,11 +273,25 @@ class FanGroup(GroupEntity, FanEntity): """Update state and attributes.""" self._attr_assumed_state = False - on_states: list[State] = list( - filter(None, [self.hass.states.get(x) for x in self._entities]) + states = [ + state + for entity_id in self._entities + if (state := self.hass.states.get(entity_id)) is not None + ] + self._attr_assumed_state |= not states_equal(states) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) + + valid_state = any( + state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) - self._is_on = any(state.state == STATE_ON for state in on_states) - self._attr_assumed_state |= not states_equal(on_states) + if not valid_state: + # Set as unknown if all members are unknown or unavailable + self._is_on = None + else: + # Set as ON if any member is ON + self._is_on = any(state.state == STATE_ON for state in states) percentage_states = self._async_states_by_support_flag( FanEntityFeature.SET_SPEED @@ -306,5 +323,5 @@ class FanGroup(GroupEntity, FanEntity): ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 ) self._attr_assumed_state |= any( - state.attributes.get(ATTR_ASSUMED_STATE) for state in on_states + state.attributes.get(ATTR_ASSUMED_STATE) for state in states ) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index b9741085c2d..e0645da6141 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -46,7 +46,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -214,15 +214,15 @@ class LightGroup(GroupEntity, LightEntity): @callback def async_update_group_state(self) -> None: """Query all members and determine the light group state.""" - all_states = [self.hass.states.get(x) for x in self._entity_ids] - states: list[State] = list(filter(None, all_states)) + states = [ + state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] on_states = [state for state in states if state.state == STATE_ON] - # filtered_states are members currently in the state machine - filtered_states: list[str] = [x.state for x in all_states if x is not None] - valid_state = self.mode( - state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states + state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) if not valid_state: @@ -230,9 +230,7 @@ class LightGroup(GroupEntity, LightEntity): self._attr_is_on = None else: # Set as ON if any / all member is ON - self._attr_is_on = self.mode( - list(map(lambda x: x == STATE_ON, filtered_states)) - ) + self._attr_is_on = self.mode(state.state == STATE_ON for state in states) self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index fe9503137c6..610e15f3ecc 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -166,7 +166,7 @@ class LockGroup(GroupEntity, LockEntity): if (state := self.hass.states.get(entity_id)) is not None ] - valid_state = all( + valid_state = any( state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 46c019dbc7c..e0cbf84a693 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -103,6 +103,8 @@ async def async_setup_entry( class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" + _attr_available: bool = False + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a Media Group entity.""" self._name = name @@ -390,19 +392,29 @@ class MediaPlayerGroup(MediaPlayerEntity): @callback def async_update_state(self) -> None: """Query all members and determine the media group state.""" - states = [self.hass.states.get(entity) for entity in self._entities] - states_values = [state.state for state in states if state is not None] - off_values = STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN + states = [ + state.state + for entity_id in self._entities + if (state := self.hass.states.get(entity_id)) is not None + ] - if states_values: - if states_values.count(states_values[0]) == len(states_values): - self._state = states_values[0] - elif any(state for state in states_values if state not in off_values): + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state != STATE_UNAVAILABLE for state in states) + + valid_state = any( + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states + ) + if not valid_state: + # Set as unknown if all members are unknown or unavailable + self._state = None + else: + off_values = (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN) + if states.count(states[0]) == len(states): + self._state = states[0] + elif any(state for state in states if state not in off_values): self._state = STATE_ON else: self._state = STATE_OFF - else: - self._state = None supported_features = 0 if self._features[KEY_CLEAR_PLAYLIST]: diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 6b879e55cea..8b60e1f1402 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -170,4 +170,5 @@ class SwitchGroup(GroupEntity, SwitchEntity): # Set as ON if any / all member is ON self._attr_is_on = self.mode(state == STATE_ON for state in states) + # Set group as unavailable if all members are unavailable or missing self._attr_available = any(state != STATE_UNAVAILABLE for state in states) diff --git a/homeassistant/components/group/translations/bg.json b/homeassistant/components/group/translations/bg.json index 6be0657c774..dea9870e1b7 100644 --- a/homeassistant/components/group/translations/bg.json +++ b/homeassistant/components/group/translations/bg.json @@ -8,11 +8,24 @@ }, "title": "\u041d\u043e\u0432\u0430 \u0433\u0440\u0443\u043f\u0430" }, + "fan": { + "data": { + "name": "\u0418\u043c\u0435" + }, + "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0430" + }, + "light": { + "data": { + "name": "\u0418\u043c\u0435" + }, + "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0430" + }, "lock": { "data": { "entities": "\u0427\u043b\u0435\u043d\u043e\u0432\u0435", "name": "\u0418\u043c\u0435" - } + }, + "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0430" }, "media_player": { "data": { diff --git a/homeassistant/components/group/translations/es.json b/homeassistant/components/group/translations/es.json index 67742954447..c58cb744a92 100644 --- a/homeassistant/components/group/translations/es.json +++ b/homeassistant/components/group/translations/es.json @@ -3,34 +3,43 @@ "step": { "binary_sensor": { "data": { - "hide_members": "Esconde miembros" + "all": "Todas las entidades", + "entities": "Miembros", + "hide_members": "Esconde miembros", + "name": "Nombre" }, + "description": "Si \"todas las entidades\" est\u00e1n habilitadas, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1n deshabilitadas, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado.", "title": "Agregar grupo" }, "cover": { "data": { + "entities": "Miembros", "hide_members": "Esconde miembros", "name": "Nombre del Grupo" - } + }, + "title": "A\u00f1adir grupo" }, "fan": { "data": { "entities": "Miembros", - "hide_members": "Esconde miembros" + "hide_members": "Esconde miembros", + "name": "Nombre" }, "title": "Agregar grupo" }, "light": { "data": { "entities": "Miembros", - "hide_members": "Esconde miembros" + "hide_members": "Esconde miembros", + "name": "Nombre" }, "title": "Agregar grupo" }, "lock": { "data": { "entities": "Miembros", - "hide_members": "Esconde miembros" + "hide_members": "Esconde miembros", + "name": "Nombre" }, "title": "Agregar grupo" }, @@ -44,16 +53,24 @@ }, "switch": { "data": { - "entities": "Miembros" - } + "entities": "Miembros", + "hide_members": "Ocultar miembros", + "name": "Nombre" + }, + "title": "A\u00f1adir grupo" }, "user": { + "description": "Los grupos permiten crear una nueva entidad que representa a varias entidades del mismo tipo.", "menu_options": { "binary_sensor": "Grupo de sensores binarios", "cover": "Grupo de cubiertas", "fan": "Grupo de ventiladores", + "light": "Grupo de luz", + "lock": "Bloquear el grupo", + "media_player": "Grupo de reproductores multimedia", "switch": "Grupo de conmutadores" - } + }, + "title": "A\u00f1adir grupo" } } }, @@ -62,28 +79,35 @@ "binary_sensor": { "data": { "all": "Todas las entidades", + "entities": "Miembros", "hide_members": "Esconde miembros" - } + }, + "description": "Si \"todas las entidades\" est\u00e1 habilitado, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1 deshabilitado, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado." }, "cover": { - "data": { - "hide_members": "Esconde miembros" - } - }, - "fan": { - "data": { - "hide_members": "Esconde miembros" - } - }, - "light": { "data": { "entities": "Miembros", "hide_members": "Esconde miembros" } }, + "fan": { + "data": { + "entities": "Miembros", + "hide_members": "Esconde miembros" + } + }, + "light": { + "data": { + "all": "Todas las entidades", + "entities": "Miembros", + "hide_members": "Esconde miembros" + }, + "description": "Si \"todas las entidades\" est\u00e1 habilitado, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1 deshabilitado, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado." + }, "lock": { "data": { - "entities": "Miembros" + "entities": "Miembros", + "hide_members": "Ocultar miembros" } }, "media_player": { @@ -91,6 +115,14 @@ "entities": "Miembros", "hide_members": "Esconde miembros" } + }, + "switch": { + "data": { + "all": "Todas las entidades", + "entities": "Miembros", + "hide_members": "Ocultar miembros" + }, + "description": "Si \"todas las entidades\" est\u00e1 habilitado, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1 deshabilitado, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado." } } }, diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json index 354a435f491..a2507082dda 100644 --- a/homeassistant/components/group/translations/he.json +++ b/homeassistant/components/group/translations/he.json @@ -9,7 +9,7 @@ "name": "\u05e9\u05dd" }, "description": "\u05d0\u05dd \u05d4\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \"\u05db\u05dc \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea\" \u05d6\u05de\u05d9\u05e0\u05d4, \u05de\u05e6\u05d1 \u05d4\u05e7\u05d1\u05d5\u05e6\u05d4 \u05de\u05d5\u05e4\u05e2\u05dc \u05e8\u05e7 \u05d0\u05dd \u05db\u05dc \u05d4\u05d7\u05d1\u05e8\u05d9\u05dd \u05e4\u05d5\u05e2\u05dc\u05d9\u05dd. \u05d0\u05dd \"\u05db\u05dc \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea\" \u05d0\u05d9\u05e0\u05d5 \u05d6\u05de\u05d9\u05df, \u05de\u05e6\u05d1 \u05d4\u05e7\u05d1\u05d5\u05e6\u05d4 \u05de\u05d5\u05e4\u05e2\u05dc \u05d0\u05dd \u05d7\u05d1\u05e8 \u05db\u05dc\u05e9\u05d4\u05d5 \u05e4\u05d5\u05e2\u05dc.", - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "cover": { "data": { @@ -17,7 +17,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "fan": { "data": { @@ -25,7 +25,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "light": { "data": { @@ -33,7 +33,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "lock": { "data": { @@ -41,7 +41,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "media_player": { "data": { @@ -49,7 +49,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "switch": { "data": { @@ -57,7 +57,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "user": { "description": "\u05e7\u05d1\u05d5\u05e6\u05d5\u05ea \u05de\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05da \u05dc\u05d9\u05e6\u05d5\u05e8 \u05d9\u05e9\u05d5\u05ea \u05d7\u05d3\u05e9\u05d4 \u05d4\u05de\u05d9\u05d9\u05e6\u05d2\u05ea \u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05de\u05e8\u05d5\u05d1\u05d5\u05ea \u05de\u05d0\u05d5\u05ea\u05d5 \u05e1\u05d5\u05d2.", @@ -68,7 +68,7 @@ "light": "\u05e7\u05d1\u05d5\u05e6\u05ea \u05ea\u05d0\u05d5\u05e8\u05d4", "media_player": "\u05e7\u05d1\u05d5\u05e6\u05ea \u05e0\u05d2\u05e0\u05d9 \u05de\u05d3\u05d9\u05d4" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" } } }, diff --git a/homeassistant/components/group/translations/sv.json b/homeassistant/components/group/translations/sv.json index 77ae43cb1ee..c62d9e18ffc 100644 --- a/homeassistant/components/group/translations/sv.json +++ b/homeassistant/components/group/translations/sv.json @@ -9,8 +9,20 @@ }, "title": "Ny grupp" }, + "light": { + "title": "L\u00e4gg till grupp" + }, "user": { - "title": "Ny grupp" + "title": "L\u00e4gg till grupp" + } + } + }, + "options": { + "step": { + "binary_sensor": { + "data": { + "all": "Alla entiteter" + } } } }, diff --git a/homeassistant/components/growatt_server/translations/sv.json b/homeassistant/components/growatt_server/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/growatt_server/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/sv.json b/homeassistant/components/habitica/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/habitica/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/translations/de.json b/homeassistant/components/hangouts/translations/de.json index b26618940be..53225644a2d 100644 --- a/homeassistant/components/hangouts/translations/de.json +++ b/homeassistant/components/hangouts/translations/de.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA PIN" }, + "description": "Leer", "title": "2-Faktor-Authentifizierung" }, "user": { diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index 4dca2192c6b..16101f18cff 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Logitech Harmony Hub integration.""" +from __future__ import annotations + import asyncio import logging from urllib.parse import urlparse @@ -137,7 +139,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/harmony/translations/bg.json b/homeassistant/components/harmony/translations/bg.json new file mode 100644 index 00000000000..b961225fc1d --- /dev/null +++ b/homeassistant/components/harmony/translations/bg.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "flow_title": "{name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/fr.json b/homeassistant/components/harmony/translations/fr.json index 077405be95f..9c9b8a7b7e1 100644 --- a/homeassistant/components/harmony/translations/fr.json +++ b/homeassistant/components/harmony/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "link": { - "description": "Voulez-vous configurer {name} ( {host} ) ?", + "description": "Voulez-vous configurer {name} ({host})\u00a0?", "title": "Configuration de Logitech Harmony Hub" }, "user": { diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 93d902e4bae..cd3c704d4c9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -206,7 +206,16 @@ MAP_SERVICE_API = { } HARDWARE_INTEGRATIONS = { - "rpi": "raspberry_pi", + "odroid-c2": "hardkernel", + "odroid-c4": "hardkernel", + "odroid-n2": "hardkernel", + "odroid-xu4": "hardkernel", + "rpi2": "raspberry_pi", + "rpi3": "raspberry_pi", + "rpi3-64": "raspberry_pi", + "rpi4": "raspberry_pi", + "rpi4-64": "raspberry_pi", + "yellow": "homeassistant_yellow", } @@ -505,7 +514,7 @@ def get_supervisor_ip() -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up the Hass.io component.""" # Check local setup - for env in ("HASSIO", "HASSIO_TOKEN"): + for env in ("SUPERVISOR", "SUPERVISOR_TOKEN"): if os.environ.get(env): continue _LOGGER.error("Missing %s environment variable", env) @@ -517,7 +526,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async_load_websocket_api(hass) - host = os.environ["HASSIO"] + host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 2d76a758096..f52a8ef0617 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -42,7 +42,7 @@ class HassIOBaseAuth(HomeAssistantView): def _check_access(self, request: web.Request): """Check if this call is from Supervisor.""" # Check caller IP - hassio_ip = os.environ["HASSIO"].split(":")[0] + hassio_ip = os.environ["SUPERVISOR"].split(":")[0] if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address( hassio_ip ): diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 2d99b1f5605..e4991e5fc03 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -25,8 +25,7 @@ ATTR_METHOD = "method" ATTR_RESULT = "result" ATTR_TIMEOUT = "timeout" - -X_HASSIO = "X-Hassio-Key" +X_AUTH_TOKEN = "X-Supervisor-Token" X_INGRESS_PATH = "X-Ingress-Path" X_HASS_USER_ID = "X-Hass-User-ID" X_HASS_IS_ADMIN = "X-Hass-Is-Admin" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 4146753b753..7b3ed697227 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -13,8 +13,6 @@ from homeassistant.components.http import ( ) from homeassistant.const import SERVER_PORT -from .const import X_HASSIO - _LOGGER = logging.getLogger(__name__) @@ -246,7 +244,9 @@ class HassIO: method, f"http://{self._ip}{command}", json=payload, - headers={X_HASSIO: os.environ.get("HASSIO_TOKEN", "")}, + headers={ + aiohttp.hdrs.AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" + }, timeout=aiohttp.ClientTimeout(total=timeout), ) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 532b947ac49..7d2e79956cc 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -11,6 +11,7 @@ import aiohttp from aiohttp import web from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( + AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, @@ -18,16 +19,18 @@ from aiohttp.hdrs import ( TRANSFER_ENCODING, ) from aiohttp.web_exceptions import HTTPBadGateway +from multidict import istr from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.onboarding import async_is_onboarded -from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID, X_HASSIO +from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID _LOGGER = logging.getLogger(__name__) MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 +# pylint: disable=implicit-str-concat NO_TIMEOUT = re.compile( r"^(?:" r"|homeassistant/update" @@ -44,10 +47,11 @@ NO_TIMEOUT = re.compile( NO_AUTH_ONBOARDING = re.compile(r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r")$") NO_AUTH = re.compile( - r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" + r"^(?:" r"|app/.*" r"|[store\/]*addons/[^/]+/(logo|dark_logo|icon|dark_icon)" r")$" ) NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") +# pylint: enable=implicit-str-concat class HassIOView(HomeAssistantView): @@ -87,7 +91,7 @@ class HassIOView(HomeAssistantView): if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary headers[ - "Content-Type" + CONTENT_TYPE ] = request._stored_content_type # pylint: disable=protected-access try: @@ -121,17 +125,17 @@ class HassIOView(HomeAssistantView): raise HTTPBadGateway() -def _init_header(request: web.Request) -> dict[str, str]: +def _init_header(request: web.Request) -> dict[istr, str]: """Create initial header.""" headers = { - X_HASSIO: os.environ.get("HASSIO_TOKEN", ""), + AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}", CONTENT_TYPE: request.content_type, } # Add user data if request.get("hass_user") is not None: - headers[X_HASS_USER_ID] = request["hass_user"].id - headers[X_HASS_IS_ADMIN] = str(int(request["hass_user"].is_admin)) + headers[istr(X_HASS_USER_ID)] = request["hass_user"].id + headers[istr(X_HASS_IS_ADMIN)] = str(int(request["hass_user"].is_admin)) return headers diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 284ba42b3c1..6caa97b788f 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -15,7 +15,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import X_HASSIO, X_INGRESS_PATH +from .const import X_AUTH_TOKEN, X_INGRESS_PATH _LOGGER = logging.getLogger(__name__) @@ -183,7 +183,7 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st headers[name] = value # Inject token / cleanup later on Supervisor - headers[X_HASSIO] = os.environ.get("HASSIO_TOKEN", "") + headers[X_AUTH_TOKEN] = os.environ.get("SUPERVISOR_TOKEN", "") # Ingress information headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index 1039a0237a8..b1fc208de80 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -6,8 +6,8 @@ from homeassistant.core import HomeAssistant, callback from . import get_host_info, get_info, get_os_info, get_supervisor_info -SUPERVISOR_PING = f"http://{os.environ['HASSIO']}/supervisor/ping" -OBSERVER_URL = f"http://{os.environ['HASSIO']}:4357" +SUPERVISOR_PING = f"http://{os.environ['SUPERVISOR']}/supervisor/ping" +OBSERVER_URL = f"http://{os.environ['SUPERVISOR']}:4357" @callback diff --git a/homeassistant/components/hassio/translations/bg.json b/homeassistant/components/hassio/translations/bg.json index 941c3601bea..a5581901d78 100644 --- a/homeassistant/components/hassio/translations/bg.json +++ b/homeassistant/components/hassio/translations/bg.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "agent_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 \u0430\u0433\u0435\u043d\u0442\u0430", "disk_total": "\u0414\u0438\u0441\u043a \u043e\u0431\u0449\u043e", "disk_used": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d \u0434\u0438\u0441\u043a", "docker_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 Docker", diff --git a/homeassistant/components/hassio/translations/el.json b/homeassistant/components/hassio/translations/el.json index ba3a52f4bbd..9e9b32d7ce3 100644 --- a/homeassistant/components/hassio/translations/el.json +++ b/homeassistant/components/hassio/translations/el.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "agent_version": "\u0388\u03ba\u03b4\u03bf\u03c3\u03b7 Agent", "board": "\u03a0\u03bb\u03b1\u03ba\u03ad\u03c4\u03b1", "disk_total": "\u03a3\u03cd\u03bd\u03bf\u03bb\u03bf \u03b4\u03af\u03c3\u03ba\u03bf\u03c5", "disk_used": "\u0394\u03af\u03c3\u03ba\u03bf\u03c2 \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9", diff --git a/homeassistant/components/hassio/translations/es.json b/homeassistant/components/hassio/translations/es.json index da3730fa45b..4a6fda89d84 100644 --- a/homeassistant/components/hassio/translations/es.json +++ b/homeassistant/components/hassio/translations/es.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "agent_version": "Versi\u00f3n del agente", "board": "Placa", "disk_total": "Disco total", "disk_used": "Disco usado", diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 4af090d7154..7eb037d8432 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -38,9 +38,11 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema( ) # Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` +# pylint: disable=implicit-str-concat WS_NO_ADMIN_ENDPOINTS = re.compile( r"^(?:" r"|/ingress/(session|validate_session)" r"|/addons/[^/]+/info" r")$" ) +# pylint: enable=implicit-str-concat _LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/homeassistant/components/heos/translations/bg.json b/homeassistant/components/heos/translations/bg.json index ced8d049372..aa967795627 100644 --- a/homeassistant/components/heos/translations/bg.json +++ b/homeassistant/components/heos/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/heos/translations/sv.json b/homeassistant/components/heos/translations/sv.json index a2cec73f291..100f8fd83a5 100644 --- a/homeassistant/components/heos/translations/sv.json +++ b/homeassistant/components/heos/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/here_travel_time/translations/bg.json b/homeassistant/components/here_travel_time/translations/bg.json new file mode 100644 index 00000000000..75bb03c2a1f --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "destination_coordinates": { + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0435\u0441\u0442\u0438\u043d\u0430\u0446\u0438\u044f" + }, + "destination_entity_id": { + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0435\u0441\u0442\u0438\u043d\u0430\u0446\u0438\u044f" + }, + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/here_travel_time/translations/es.json b/homeassistant/components/here_travel_time/translations/es.json new file mode 100644 index 00000000000..c1a8d9cef11 --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/es.json @@ -0,0 +1,82 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "destination_coordinates": { + "data": { + "destination": "Destino como coordenadas GPS" + }, + "title": "Elija el destino" + }, + "destination_entity_id": { + "data": { + "destination_entity_id": "Destino usando una entidad" + }, + "title": "Elija el destino" + }, + "destination_menu": { + "menu_options": { + "destination_coordinates": "Usando una ubicaci\u00f3n en el mapa", + "destination_entity": "Usando una entidad" + }, + "title": "Elija el destino" + }, + "origin_coordinates": { + "data": { + "origin": "Origen como coordenadas GPS" + }, + "title": "Elija el origen" + }, + "origin_entity_id": { + "data": { + "origin_entity_id": "Origen usando una entidad" + }, + "title": "Elija el origen" + }, + "user": { + "data": { + "api_key": "Clave API", + "mode": "Modo de viaje", + "name": "Nombre" + } + } + } + }, + "options": { + "step": { + "arrival_time": { + "data": { + "arrival_time": "Hora de llegada" + }, + "title": "Elija la hora de llegada" + }, + "departure_time": { + "data": { + "departure_time": "Hora de salida" + }, + "title": "Elija la hora de salida" + }, + "init": { + "data": { + "route_mode": "Modo de ruta", + "traffic_mode": "Modo de tr\u00e1fico", + "unit_system": "Sistema de unidades" + } + }, + "time_menu": { + "menu_options": { + "arrival_time": "Configurar una hora de llegada", + "departure_time": "Configurar una hora de salida", + "no_time": "No configurar una hora" + }, + "title": "Elija el tipo de hora" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/here_travel_time/translations/he.json b/homeassistant/components/here_travel_time/translations/he.json new file mode 100644 index 00000000000..dc5eb786f67 --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/here_travel_time/translations/pt-BR.json b/homeassistant/components/here_travel_time/translations/pt-BR.json index 78996561564..34f862f0029 100644 --- a/homeassistant/components/here_travel_time/translations/pt-BR.json +++ b/homeassistant/components/here_travel_time/translations/pt-BR.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" }, "error": { "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", @@ -41,7 +41,7 @@ }, "user": { "data": { - "api_key": "Chave API", + "api_key": "Chave da API", "mode": "Modo de viagem", "name": "Nome" } diff --git a/homeassistant/components/here_travel_time/translations/sv.json b/homeassistant/components/here_travel_time/translations/sv.json new file mode 100644 index 00000000000..0757cc44bf1 --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 27acff54f99..77301532d3d 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -24,10 +24,10 @@ from homeassistant.components.recorder.statistics import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.websocket_api import messages -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA +from homeassistant.helpers.json import JSON_DUMP from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 3b17c715c97..33f32e72292 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -23,6 +23,14 @@ class HistoryStatsState: period: tuple[datetime.datetime, datetime.datetime] +@dataclass +class HistoryState: + """A minimal state to avoid holding on to State objects.""" + + state: str + last_changed: float + + class HistoryStats: """Manage history stats.""" @@ -40,7 +48,7 @@ class HistoryStats: self.entity_id = entity_id self._period = (MIN_TIME_UTC, MIN_TIME_UTC) self._state: HistoryStatsState = HistoryStatsState(None, None, self._period) - self._history_current_period: list[State] = [] + self._history_current_period: list[HistoryState] = [] self._previous_run_before_start = False self._entity_states = set(entity_states) self._duration = duration @@ -103,20 +111,18 @@ class HistoryStats: <= floored_timestamp(new_state.last_changed) <= current_period_end_timestamp ): - self._history_current_period.append(new_state) + self._history_current_period.append( + HistoryState( + new_state.state, new_state.last_changed.timestamp() + ) + ) new_data = True if not new_data and current_period_end_timestamp < now_timestamp: # If period has not changed and current time after the period end... # Don't compute anything as the value cannot have changed return self._state else: - self._history_current_period = await get_instance( - self.hass - ).async_add_executor_job( - self._update_from_database, - current_period_start, - current_period_end, - ) + await self._async_history_from_db(current_period_start, current_period_end) self._previous_run_before_start = False hours_matched, match_count = self._async_compute_hours_and_changes( @@ -127,7 +133,24 @@ class HistoryStats: self._state = HistoryStatsState(hours_matched, match_count, self._period) return self._state - def _update_from_database( + async def _async_history_from_db( + self, + current_period_start: datetime.datetime, + current_period_end: datetime.datetime, + ) -> None: + """Update history data for the current period from the database.""" + instance = get_instance(self.hass) + states = await instance.async_add_executor_job( + self._state_changes_during_period, + current_period_start, + current_period_end, + ) + self._history_current_period = [ + HistoryState(state.state, state.last_changed.timestamp()) + for state in states + ] + + def _state_changes_during_period( self, start: datetime.datetime, end: datetime.datetime ) -> list[State]: return history.state_changes_during_period( @@ -155,9 +178,9 @@ class HistoryStats: match_count = 1 if previous_state_matches else 0 # Make calculations - for item in self._history_current_period: - current_state_matches = item.state in self._entity_states - state_change_timestamp = item.last_changed.timestamp() + for history_state in self._history_current_period: + current_state_matches = history_state.state in self._entity_states + state_change_timestamp = history_state.last_changed if previous_state_matches: elapsed += state_change_timestamp - last_state_change_timestamp diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index b0ce1a8fca5..a42c516f12b 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( TIME_HOURS, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service @@ -101,6 +102,9 @@ async def async_setup_platform( history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration) coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, name) + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise PlatformNotReady from coordinator.last_exception async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name)]) @@ -152,6 +156,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase): super().__init__(coordinator, name) self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type + self._process_update() @callback def _process_update(self) -> None: diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index f3ed9674fcd..52cf7f719e6 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS @@ -132,8 +132,17 @@ class HiveEntity(Entity): """Initialize the instance.""" self.hive = hive self.device = hive_device + self._attr_name = self.device["haName"] + self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + model=self.device["deviceData"]["model"], + manufacturer=self.device["deviceData"]["manufacturer"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) self.attributes = {} - self._unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' async def async_added_to_hass(self): """When entity is added to Home Assistant.""" diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index a3509fce66f..48b59e351be 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for the Hive alarm.""" +from __future__ import annotations + from datetime import timedelta from homeassistant.components.alarm_control_panel import ( @@ -13,7 +15,6 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity @@ -50,53 +51,25 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this AdGuard Home instance.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - model=self.device["deviceData"]["model"], - manufacturer=self.device["deviceData"]["manufacturer"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the name of the alarm.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def state(self): - """Return state of alarm.""" - if self.device["status"]["state"]: - return STATE_ALARM_TRIGGERED - return HIVETOHA[self.device["status"]["mode"]] - - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self.hive.alarm.setMode(self.device, "home") - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.hive.alarm.setMode(self.device, "asleep") - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.hive.alarm.setMode(self.device, "away") - async def async_update(self): + async def async_update(self) -> None: """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.alarm.getAlarm(self.device) + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + if self.device["status"]["state"]: + self._attr_state = STATE_ALARM_TRIGGERED + else: + self._attr_state = HIVETOHA[self.device["status"]["mode"]] diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 934974d3c1e..313c78275e7 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -4,27 +4,46 @@ from datetime import timedelta from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity -from .const import ATTR_MODE, DOMAIN +from .const import DOMAIN -DEVICETYPE = { - "contactsensor": BinarySensorDeviceClass.OPENING, - "motionsensor": BinarySensorDeviceClass.MOTION, - "Connectivity": BinarySensorDeviceClass.CONNECTIVITY, - "SMOKE_CO": BinarySensorDeviceClass.SMOKE, - "DOG_BARK": BinarySensorDeviceClass.SOUND, - "GLASS_BREAK": BinarySensorDeviceClass.SOUND, -} PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="contactsensor", device_class=BinarySensorDeviceClass.OPENING + ), + BinarySensorEntityDescription( + key="motionsensor", + device_class=BinarySensorDeviceClass.MOTION, + ), + BinarySensorEntityDescription( + key="Connectivity", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="SMOKE_CO", + device_class=BinarySensorDeviceClass.SMOKE, + ), + BinarySensorEntityDescription( + key="DOG_BARK", + device_class=BinarySensorDeviceClass.SOUND, + ), + BinarySensorEntityDescription( + key="GLASS_BREAK", + device_class=BinarySensorDeviceClass.SOUND, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -34,62 +53,28 @@ async def async_setup_entry( devices = hive.session.deviceList.get("binary_sensor") entities = [] if devices: - for dev in devices: - entities.append(HiveBinarySensorEntity(hive, dev)) + for description in BINARY_SENSOR_TYPES: + for dev in devices: + if dev["hiveType"] == description.key: + entities.append(HiveBinarySensorEntity(hive, dev, description)) async_add_entities(entities, True) class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): """Representation of a Hive binary sensor.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICETYPE.get(self.device["hiveType"]) - - @property - def name(self): - """Return the name of the binary sensor.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - if self.device["hiveType"] != "Connectivity": - return self.device["deviceData"]["online"] - return True - - @property - def extra_state_attributes(self): - """Show Device Attributes.""" - return { - ATTR_MODE: self.attributes.get(ATTR_MODE), - } - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.device["status"]["state"] + def __init__(self, hive, hive_device, entity_description): + """Initialise hive binary sensor.""" + super().__init__(hive, hive_device) + self.entity_description = entity_description async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) self.attributes = self.device.get("attributes", {}) + self._attr_is_on = self.device["status"]["state"] + if self.device["hiveType"] != "Connectivity": + self._attr_available = self.device["deviceData"].get("online") + else: + self._attr_available = True diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index d094ca9eace..d6dfcfa6b2c 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity, refresh_system @@ -46,8 +45,6 @@ HIVE_TO_HASS_HVAC_ACTION = { } TEMP_UNIT = {"C": TEMP_CELSIUS, "F": TEMP_FAHRENHEIT} - -SUPPORT_PRESET = [PRESET_NONE, PRESET_BOOST] PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) _LOGGER = logging.getLogger() @@ -105,6 +102,7 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Hive Climate Device.""" _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] + _attr_preset_modes = [PRESET_BOOST, PRESET_NONE] _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) @@ -113,84 +111,7 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Initialize the Climate device.""" super().__init__(hive_session, hive_device) self.thermostat_node_id = hive_device["device_id"] - self.temperature_type = TEMP_UNIT.get(hive_device["temperatureunit"]) - - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the name of the Climate device.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - return HIVE_TO_HASS_STATE[self.device["status"]["mode"]] - - @property - def hvac_action(self) -> HVACAction: - """Return current HVAC action.""" - return HIVE_TO_HASS_HVAC_ACTION[self.device["status"]["action"]] - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self.temperature_type - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.device["status"]["current_temperature"] - - @property - def target_temperature(self): - """Return the target temperature.""" - return self.device["status"]["target_temperature"] - - @property - def min_temp(self): - """Return minimum temperature.""" - return self.device["min_temp"] - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self.device["max_temp"] - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - if self.device["status"]["boost"] == "ON": - return PRESET_BOOST - return PRESET_NONE - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return SUPPORT_PRESET + self._attr_temperature_unit = TEMP_UNIT.get(hive_device["temperatureunit"]) @refresh_system async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -236,3 +157,19 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.heating.getClimate(self.device) + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_hvac_mode = HIVE_TO_HASS_STATE[self.device["status"]["mode"]] + self._attr_hvac_action = HIVE_TO_HASS_HVAC_ACTION[ + self.device["status"]["action"] + ] + self._attr_current_temperature = self.device["status"][ + "current_temperature" + ] + self._attr_target_temperature = self.device["status"]["target_temperature"] + self._attr_min_temp = self.device["min_temp"] + self._attr_max_temp = self.device["max_temp"] + if self.device["status"]["boost"] == "ON": + self._attr_preset_mode = PRESET_BOOST + else: + self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 90c78aefcbd..ec1c4f78e87 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -1,4 +1,8 @@ """Config Flow for Hive.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any from apyhiveapi import Auth from apyhiveapi.helper.hive_exceptions import ( @@ -12,8 +16,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult -from .const import CONF_CODE, CONFIG_ENTRY_VERSION, DOMAIN +from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -28,6 +33,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.tokens = {} self.entry = None self.device_registration = False + self.device_name = "Home Assistant" async def async_step_user(self, user_input=None): """Prompt user input. Create or edit entry.""" @@ -59,7 +65,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_2fa() if not errors: - # Complete the entry setup. + # Complete the entry. try: return await self.async_setup_hive_entry() except UnknownHiveError: @@ -88,15 +94,36 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "no_internet_available" if not errors: - try: - self.device_registration = True + if self.context["source"] == config_entries.SOURCE_REAUTH: return await self.async_setup_hive_entry() - except UnknownHiveError: - errors["base"] = "unknown" + self.device_registration = True + return await self.async_step_configuration() schema = vol.Schema({vol.Required(CONF_CODE): str}) return self.async_show_form(step_id="2fa", data_schema=schema, errors=errors) + async def async_step_configuration(self, user_input=None): + """Handle hive configuration step.""" + errors = {} + + if user_input: + if self.device_registration: + self.device_name = user_input["device_name"] + await self.hive_auth.device_registration(user_input["device_name"]) + self.data["device_data"] = await self.hive_auth.get_device_data() + + try: + return await self.async_setup_hive_entry() + except UnknownHiveError: + errors["base"] = "unknown" + + schema = vol.Schema( + {vol.Optional(CONF_DEVICE_NAME, default=self.device_name): str} + ) + return self.async_show_form( + step_id="configuration", data_schema=schema, errors=errors + ) + async def async_setup_hive_entry(self): """Finish setup and create the config entry.""" @@ -104,9 +131,6 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): raise UnknownHiveError # Setup the config entry - if self.device_registration: - await self.hive_auth.device_registration("Home Assistant") - self.data["device_data"] = await self.hive_auth.getDeviceData() self.data["tokens"] = self.tokens if self.context["source"] == config_entries.SOURCE_REAUTH: self.hass.config_entries.async_update_entry( @@ -116,11 +140,11 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self.data["username"], data=self.data) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Re Authenticate a user.""" data = { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: entry_data[CONF_USERNAME], + CONF_PASSWORD: entry_data[CONF_PASSWORD], } return await self.async_step_user(data) @@ -130,7 +154,9 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> HiveOptionsFlowHandler: """Hive options callback.""" return HiveOptionsFlowHandler(config_entry) @@ -138,7 +164,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class HiveOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options for Hive.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Hive options flow.""" self.hive = None self.config_entry = config_entry diff --git a/homeassistant/components/hive/const.py b/homeassistant/components/hive/const.py index 82c07761eef..b7a2be6910f 100644 --- a/homeassistant/components/hive/const.py +++ b/homeassistant/components/hive/const.py @@ -5,6 +5,7 @@ ATTR_MODE = "mode" ATTR_TIME_PERIOD = "time_period" ATTR_ONOFF = "on_off" CONF_CODE = "2fa" +CONF_DEVICE_NAME = "device_name" CONFIG_ENTRY_VERSION = 1 DEFAULT_NAME = "Hive" DOMAIN = "hive" diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index ba095896b64..c06237f3709 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -12,7 +12,6 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util @@ -40,72 +39,18 @@ async def async_setup_entry( class HiveDeviceLight(HiveEntity, LightEntity): """Hive Active Light Device.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id + def __init__(self, hive, hive_device): + """Initialise hive light.""" + super().__init__(hive, hive_device) + if self.device["hiveType"] == "warmwhitelight": + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + elif self.device["hiveType"] == "tuneablelight": + self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} + elif self.device["hiveType"] == "colourtuneablelight": + self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the display name of this light.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def extra_state_attributes(self): - """Show Device Attributes.""" - return { - ATTR_MODE: self.attributes.get(ATTR_MODE), - } - - @property - def brightness(self): - """Brightness of the light (an integer in the range 1-255).""" - return self.device["status"]["brightness"] - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return self.device.get("min_mireds") - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return self.device.get("max_mireds") - - @property - def color_temp(self): - """Return the CT color value in mireds.""" - return self.device["status"].get("color_temp") - - @property - def hs_color(self): - """Return the hs color value.""" - if self.device["status"]["mode"] == "COLOUR": - rgb = self.device["status"].get("hs_color") - return color_util.color_RGB_to_hs(*rgb) - return None - - @property - def is_on(self): - """Return true if light is on.""" - return self.device["status"]["state"] + self._attr_min_mireds = self.device.get("min_mireds") + self._attr_max_mireds = self.device.get("max_mireds") @refresh_system async def async_turn_on(self, **kwargs): @@ -137,32 +82,18 @@ class HiveDeviceLight(HiveEntity, LightEntity): """Instruct the light to turn off.""" await self.hive.light.turnOff(self.device) - @property - def color_mode(self) -> str: - """Return the color mode of the light.""" - if self.device["hiveType"] == "warmwhitelight": - return ColorMode.BRIGHTNESS - if self.device["hiveType"] == "tuneablelight": - return ColorMode.COLOR_TEMP - if self.device["hiveType"] == "colourtuneablelight": - if self.device["status"]["mode"] == "COLOUR": - return ColorMode.HS - return ColorMode.COLOR_TEMP - return ColorMode.ONOFF - - @property - def supported_color_modes(self) -> set[str] | None: - """Flag supported color modes.""" - if self.device["hiveType"] == "warmwhitelight": - return {ColorMode.BRIGHTNESS} - if self.device["hiveType"] == "tuneablelight": - return {ColorMode.COLOR_TEMP} - if self.device["hiveType"] == "colourtuneablelight": - return {ColorMode.COLOR_TEMP, ColorMode.HS} - return {ColorMode.ONOFF} - async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.light.getLight(self.device) self.attributes.update(self.device.get("attributes", {})) + self._attr_extra_state_attributes = { + ATTR_MODE: self.attributes.get(ATTR_MODE), + } + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_is_on = self.device["status"]["state"] + self._attr_brightness = self.device["status"]["brightness"] + if self.device["hiveType"] == "colourtuneablelight": + rgb = self.device["status"]["hs_color"] + self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index bc07a251779..406b32d86f8 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -2,8 +2,11 @@ "domain": "hive", "name": "Hive", "config_flow": true, + "homekit": { + "models": ["HHKBridge*"] + }, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.5.10"], + "requirements": ["pyhiveapi==0.5.13"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 68de137dee7..5bac23fdb3d 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -1,10 +1,15 @@ """Support for the Hive sensors.""" from datetime import timedelta -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, POWER_KILO_WATT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity @@ -12,71 +17,47 @@ from .const import DOMAIN PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) -DEVICETYPE = { - "Battery": {"unit": " % ", "type": SensorDeviceClass.BATTERY}, -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + SensorEntityDescription( + key="Power", + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] devices = hive.session.deviceList.get("sensor") entities = [] if devices: - for dev in devices: - entities.append(HiveSensorEntity(hive, dev)) + for description in SENSOR_TYPES: + for dev in devices: + if dev["hiveType"] == description.key: + entities.append(HiveSensorEntity(hive, dev, description)) async_add_entities(entities, True) class HiveSensorEntity(HiveEntity, SensorEntity): """Hive Sensor Entity.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def available(self): - """Return if sensor is available.""" - return self.device.get("deviceData", {}).get("online") - - @property - def device_class(self): - """Device class of the entity.""" - return DEVICETYPE[self.device["hiveType"]].get("type") - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return DEVICETYPE[self.device["hiveType"]].get("unit") - - @property - def name(self): - """Return the name of the sensor.""" - return self.device["haName"] - - @property - def native_value(self): - """Return the state of the sensor.""" - return self.device["status"]["state"] + def __init__(self, hive, hive_device, entity_description): + """Initialise hive sensor.""" + super().__init__(hive, hive_device) + self.entity_description = entity_description async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) + self._attr_native_value = self.device["status"]["state"] diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 7628abc5b06..3435517aec7 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Hive Login", - "description": "Enter your Hive login information and configuration.", + "description": "Enter your Hive login information.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", @@ -17,6 +17,13 @@ "2fa": "Two-factor code" } }, + "configuration": { + "data": { + "device_name": "Device Name" + }, + "description": "Enter your Hive configuration ", + "title": "Hive Configuration." + }, "reauth": { "title": "Hive Login", "description": "Re-enter your Hive login information.", diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index cb9ac79d51e..64a8276521a 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -3,10 +3,9 @@ from __future__ import annotations from datetime import timedelta -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 import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity, refresh_system @@ -16,6 +15,14 @@ PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="activeplug", + ), + SwitchEntityDescription(key="Heating_Heat_On_Demand"), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -25,54 +32,20 @@ async def async_setup_entry( devices = hive.session.deviceList.get("switch") entities = [] if devices: - for dev in devices: - entities.append(HiveDevicePlug(hive, dev)) + for description in SWITCH_TYPES: + for dev in devices: + if dev["hiveType"] == description.key: + entities.append(HiveSwitch(hive, dev, description)) async_add_entities(entities, True) -class HiveDevicePlug(HiveEntity, SwitchEntity): +class HiveSwitch(HiveEntity, SwitchEntity): """Hive Active Plug.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if self.device["hiveType"] == "activeplug": - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - return None - - @property - def name(self): - """Return the name of this Switch device if any.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"].get("online") - - @property - def extra_state_attributes(self): - """Show Device Attributes.""" - return { - ATTR_MODE: self.attributes.get(ATTR_MODE), - } - - @property - def is_on(self): - """Return true if switch is on.""" - return self.device["status"]["state"] + def __init__(self, hive, hive_device, entity_description): + """Initialise hive switch.""" + super().__init__(hive, hive_device) + self.entity_description = entity_description @refresh_system async def async_turn_on(self, **kwargs): @@ -89,3 +62,9 @@ class HiveDevicePlug(HiveEntity, SwitchEntity): await self.hive.session.updateData(self.device) self.device = await self.hive.switch.getSwitch(self.device) self.attributes.update(self.device.get("attributes", {})) + self._attr_extra_state_attributes = { + ATTR_MODE: self.attributes.get(ATTR_MODE), + } + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_is_on = self.device["status"]["state"] diff --git a/homeassistant/components/hive/translations/bg.json b/homeassistant/components/hive/translations/bg.json index ac28082fbed..082fb940fca 100644 --- a/homeassistant/components/hive/translations/bg.json +++ b/homeassistant/components/hive/translations/bg.json @@ -2,6 +2,27 @@ "config": { "error": { "no_internet_available": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e\u0441\u0442 \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0441 Hive." + }, + "step": { + "reauth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043d\u0430 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435 (\u0441\u0435\u043a\u0443\u043d\u0434\u0438)" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/hive/translations/ca.json b/homeassistant/components/hive/translations/ca.json index edebafba579..50a06169547 100644 --- a/homeassistant/components/hive/translations/ca.json +++ b/homeassistant/components/hive/translations/ca.json @@ -20,6 +20,13 @@ "description": "Introdueix codi d'autenticaci\u00f3 Hive. \n\n Introdueix el codi 0000 per demanar un altre codi.", "title": "Verificaci\u00f3 en dos passos de Hive." }, + "configuration": { + "data": { + "device_name": "Nom del dispositiu" + }, + "description": "Introdueix la teva configuraci\u00f3 de Hive ", + "title": "Configuraci\u00f3 de Hive." + }, "reauth": { "data": { "password": "Contrasenya", @@ -34,7 +41,7 @@ "scan_interval": "Interval d'escaneig (segons)", "username": "Nom d'usuari" }, - "description": "Actualitza la informaci\u00f3 i configuraci\u00f3 d'inici de sessi\u00f3.", + "description": "Introdueix la informaci\u00f3 d'inici de sessi\u00f3 de Hive.", "title": "Inici de sessi\u00f3 Hive" } } diff --git a/homeassistant/components/hive/translations/de.json b/homeassistant/components/hive/translations/de.json index bd5876bb023..e40fd0f499f 100644 --- a/homeassistant/components/hive/translations/de.json +++ b/homeassistant/components/hive/translations/de.json @@ -20,6 +20,13 @@ "description": "Gib deinen Hive-Authentifizierungscode ein. \n \nBitte gib den Code 0000 ein, um einen anderen Code anzufordern.", "title": "Hive Zwei-Faktor-Authentifizierung." }, + "configuration": { + "data": { + "device_name": "Ger\u00e4tename" + }, + "description": "Gib deine Hive-Konfiguration ein ", + "title": "Hive-Konfiguration." + }, "reauth": { "data": { "password": "Passwort", @@ -34,7 +41,7 @@ "scan_interval": "Scanintervall (Sekunden)", "username": "Benutzername" }, - "description": "Gebe deine Anmeldeinformationen und -konfiguration f\u00fcr Hive ein", + "description": "Gib deine Hive-Anmeldeinformationen ein.", "title": "Hive Anmeldung" } } diff --git a/homeassistant/components/hive/translations/el.json b/homeassistant/components/hive/translations/el.json index 986dd52ef19..3c6be9f4eba 100644 --- a/homeassistant/components/hive/translations/el.json +++ b/homeassistant/components/hive/translations/el.json @@ -20,6 +20,13 @@ "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 Hive. \n\n \u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc 0000 \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b6\u03b7\u03c4\u03ae\u03c3\u03b5\u03c4\u03b5 \u03ac\u03bb\u03bb\u03bf \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc.", "title": "\u0388\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b4\u03cd\u03bf \u03c0\u03b1\u03c1\u03b1\u03b3\u03cc\u03bd\u03c4\u03c9\u03bd \u03c4\u03bf\u03c5 Hive." }, + "configuration": { + "data": { + "device_name": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 Hive \u03c3\u03b1\u03c2", + "title": "\u0394\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 Hive." + }, "reauth": { "data": { "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", diff --git a/homeassistant/components/hive/translations/en.json b/homeassistant/components/hive/translations/en.json index 32453da0a0c..3ef7b3d0f43 100644 --- a/homeassistant/components/hive/translations/en.json +++ b/homeassistant/components/hive/translations/en.json @@ -20,6 +20,13 @@ "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.", "title": "Hive Two-factor Authentication." }, + "configuration": { + "data": { + "device_name": "Device Name" + }, + "description": "Enter your Hive configuration ", + "title": "Hive Configuration." + }, "reauth": { "data": { "password": "Password", @@ -34,7 +41,7 @@ "scan_interval": "Scan Interval (seconds)", "username": "Username" }, - "description": "Enter your Hive login information and configuration.", + "description": "Enter your Hive login information.", "title": "Hive Login" } } diff --git a/homeassistant/components/hive/translations/et.json b/homeassistant/components/hive/translations/et.json index 5cffcb036d3..d5916257589 100644 --- a/homeassistant/components/hive/translations/et.json +++ b/homeassistant/components/hive/translations/et.json @@ -20,6 +20,13 @@ "description": "Sisesta oma Hive autentimiskood. \n\n Uue koodi taotlemiseks sisesta kood 0000.", "title": "Hive kaheastmeline autentimine." }, + "configuration": { + "data": { + "device_name": "Seadme nimi" + }, + "description": "Sisesta oma Hive andmed", + "title": "Hive s\u00e4tted" + }, "reauth": { "data": { "password": "Salas\u00f5na", @@ -34,7 +41,7 @@ "scan_interval": "P\u00e4ringute intervall (sekundites)", "username": "Kasutajanimi" }, - "description": "Sisesta oma Hive sisselogimisteave ja s\u00e4tted.", + "description": "Sisesta oma Hive sisselogimisteave.", "title": "Hive sisselogimine" } } diff --git a/homeassistant/components/hive/translations/fr.json b/homeassistant/components/hive/translations/fr.json index 5868a2bf175..118a0ff9b75 100644 --- a/homeassistant/components/hive/translations/fr.json +++ b/homeassistant/components/hive/translations/fr.json @@ -20,6 +20,13 @@ "description": "Entrez votre code d\u2019authentification Hive. \n \nVeuillez entrer le code 0000 pour demander un autre code.", "title": "Authentification \u00e0 deux facteurs Hive." }, + "configuration": { + "data": { + "device_name": "Nom de l'appareil" + }, + "description": "Saisissez votre configuration Hive ", + "title": "Configuration Hive." + }, "reauth": { "data": { "password": "Mot de passe", diff --git a/homeassistant/components/hive/translations/hu.json b/homeassistant/components/hive/translations/hu.json index 9b0d3c21590..8a265ff63c0 100644 --- a/homeassistant/components/hive/translations/hu.json +++ b/homeassistant/components/hive/translations/hu.json @@ -20,6 +20,13 @@ "description": "Adja meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", "title": "Hive k\u00e9tfaktoros hiteles\u00edt\u00e9s." }, + "configuration": { + "data": { + "device_name": "Eszk\u00f6zn\u00e9v" + }, + "description": "Adja meg Hive konfigur\u00e1ci\u00f3j\u00e1t", + "title": "Hive konfigur\u00e1ci\u00f3." + }, "reauth": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/hive/translations/id.json b/homeassistant/components/hive/translations/id.json index e092515e91e..9c008d06802 100644 --- a/homeassistant/components/hive/translations/id.json +++ b/homeassistant/components/hive/translations/id.json @@ -20,6 +20,13 @@ "description": "Masukkan kode autentikasi Hive Anda. \n \nMasukkan kode 0000 untuk meminta kode lain.", "title": "Autentikasi Dua Faktor Hive." }, + "configuration": { + "data": { + "device_name": "Nama Perangkat" + }, + "description": "Masukkan konfigurasi Hive Anda", + "title": "Konfigurasi Hive" + }, "reauth": { "data": { "password": "Kata Sandi", @@ -34,7 +41,7 @@ "scan_interval": "Interval Pindai (detik)", "username": "Nama Pengguna" }, - "description": "Masukkan informasi masuk dan konfigurasi Hive Anda.", + "description": "Masukkan informasi masuk Hive Anda.", "title": "Info Masuk Hive" } } diff --git a/homeassistant/components/hive/translations/it.json b/homeassistant/components/hive/translations/it.json index 38edac70cb1..fcd841cd1d2 100644 --- a/homeassistant/components/hive/translations/it.json +++ b/homeassistant/components/hive/translations/it.json @@ -20,6 +20,13 @@ "description": "Inserisci il tuo codice di autenticazione Hive. \n\n Inserisci il codice 0000 per richiedere un altro codice.", "title": "Autenticazione a due fattori di Hive." }, + "configuration": { + "data": { + "device_name": "Nome del dispositivo" + }, + "description": "Inserisci la tua configurazione Hive ", + "title": "Configurazione Hive." + }, "reauth": { "data": { "password": "Password", @@ -34,7 +41,7 @@ "scan_interval": "Intervallo di scansione (secondi)", "username": "Nome utente" }, - "description": "Inserisci le informazioni di accesso e la configurazione di Hive.", + "description": "Inserisci le tue informazioni di accesso Hive.", "title": "Accesso Hive" } } diff --git a/homeassistant/components/hive/translations/ja.json b/homeassistant/components/hive/translations/ja.json index 55b18b13427..ed11bbd8b7e 100644 --- a/homeassistant/components/hive/translations/ja.json +++ b/homeassistant/components/hive/translations/ja.json @@ -20,6 +20,13 @@ "description": "Hive\u8a8d\u8a3c\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u307e\u3059\u3002 \n\n\u5225\u306e\u30b3\u30fc\u30c9\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u306b\u306f\u3001\u30b3\u30fc\u30c9 0000 \u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "title": "Hive 2\u8981\u7d20\u8a8d\u8a3c\u3002" }, + "configuration": { + "data": { + "device_name": "\u30c7\u30d0\u30a4\u30b9\u540d" + }, + "description": "Hive\u306e\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u307e\u3059 ", + "title": "Hive\u306e\u8a2d\u5b9a\u3002" + }, "reauth": { "data": { "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", diff --git a/homeassistant/components/hive/translations/nl.json b/homeassistant/components/hive/translations/nl.json index 2cbc7c94fc6..af0c9bcbfb2 100644 --- a/homeassistant/components/hive/translations/nl.json +++ b/homeassistant/components/hive/translations/nl.json @@ -20,6 +20,13 @@ "description": "Voer uw Hive-verificatiecode in. \n \n Voer code 0000 in om een andere code aan te vragen.", "title": "Hive tweefactorauthenticatie" }, + "configuration": { + "data": { + "device_name": "Apparaatnaam" + }, + "description": "Voer uw Hive-configuratie in", + "title": "Hive-configuratie." + }, "reauth": { "data": { "password": "Wachtwoord", diff --git a/homeassistant/components/hive/translations/no.json b/homeassistant/components/hive/translations/no.json index c5213aafeee..17241b940c4 100644 --- a/homeassistant/components/hive/translations/no.json +++ b/homeassistant/components/hive/translations/no.json @@ -20,6 +20,13 @@ "description": "Skriv inn din Hive-godkjenningskode. \n\n Vennligst skriv inn kode 0000 for \u00e5 be om en annen kode.", "title": "Hive Totrinnsbekreftelse autentisering." }, + "configuration": { + "data": { + "device_name": "Enhetsnavn" + }, + "description": "Skriv inn Hive-konfigurasjonen din", + "title": "Hive-konfigurasjon." + }, "reauth": { "data": { "password": "Passord", @@ -34,7 +41,7 @@ "scan_interval": "Skanneintervall (sekunder)", "username": "Brukernavn" }, - "description": "Skriv inn inn innloggingsinformasjonen og konfigurasjonen for Hive.", + "description": "Skriv inn Hive-p\u00e5loggingsinformasjonen din.", "title": "Hive-p\u00e5logging" } } diff --git a/homeassistant/components/hive/translations/pt-BR.json b/homeassistant/components/hive/translations/pt-BR.json index 5f5f7e857c2..3a0cf19884e 100644 --- a/homeassistant/components/hive/translations/pt-BR.json +++ b/homeassistant/components/hive/translations/pt-BR.json @@ -20,6 +20,13 @@ "description": "Digite seu c\u00f3digo de autentica\u00e7\u00e3o Hive. \n\n Insira o c\u00f3digo 0000 para solicitar outro c\u00f3digo.", "title": "Autentica\u00e7\u00e3o de dois fatores do Hive." }, + "configuration": { + "data": { + "device_name": "Nome do dispositivo" + }, + "description": "Digite sua configura\u00e7\u00e3o de Hive", + "title": "Configura\u00e7\u00e3o Hive" + }, "reauth": { "data": { "password": "Senha", @@ -34,7 +41,7 @@ "scan_interval": "Intervalo de escaneamento (segundos)", "username": "Usu\u00e1rio" }, - "description": "Insira suas informa\u00e7\u00f5es de login e configura\u00e7\u00e3o do Hive.", + "description": "Insira suas informa\u00e7\u00f5es de login de Hive.", "title": "Login do Hive" } } diff --git a/homeassistant/components/hive/translations/zh-Hant.json b/homeassistant/components/hive/translations/zh-Hant.json index 0af7e218f6e..6f23d8299a2 100644 --- a/homeassistant/components/hive/translations/zh-Hant.json +++ b/homeassistant/components/hive/translations/zh-Hant.json @@ -20,6 +20,13 @@ "description": "\u8f38\u5165 Hive \u8a8d\u8b49\u78bc\u3002\n \n \u8acb\u8f38\u5165 0000 \u4ee5\u7372\u53d6\u5176\u4ed6\u8a8d\u8b49\u78bc\u3002", "title": "\u96d9\u91cd\u8a8d\u8b49" }, + "configuration": { + "data": { + "device_name": "\u88dd\u7f6e\u540d\u7a31" + }, + "description": "\u8f38\u5165 Hive \u8a2d\u5b9a", + "title": "Hive \u8a2d\u5b9a\u3002" + }, "reauth": { "data": { "password": "\u5bc6\u78bc", @@ -34,7 +41,7 @@ "scan_interval": "\u6383\u63cf\u9593\u8ddd\uff08\u79d2\uff09", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u8f38\u5165 Hive \u767b\u5165\u8cc7\u8a0a\u8207\u8a2d\u5b9a\u3002", + "description": "\u8f38\u5165 Hive \u767b\u5165\u8cc7\u8a0a\u3002", "title": "Hive \u767b\u5165\u8cc7\u8a0a" } } diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 5e3c18fce69..0e7f2453c92 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity, refresh_system @@ -75,48 +74,8 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): """Hive Water Heater Device.""" _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE - - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the name of the water heater.""" - return HOTWATER_NAME - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_operation(self): - """Return current operation.""" - return HIVE_TO_HASS_STATE[self.device["status"]["current_operation"]] - - @property - def operation_list(self): - """List of available operation modes.""" - return SUPPORT_WATER_HEATER + _attr_temperature_unit = TEMP_CELSIUS + _attr_operation_list = SUPPORT_WATER_HEATER @refresh_system async def async_turn_on(self, **kwargs): @@ -146,3 +105,8 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.hotwater.getWaterHeater(self.device) + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_current_operation = HIVE_TO_HASS_STATE[ + self.device["status"]["current_operation"] + ] diff --git a/homeassistant/components/hlk_sw16/translations/sv.json b/homeassistant/components/hlk_sw16/translations/sv.json new file mode 100644 index 00000000000..eba844f6c03 --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 345eeeddaaa..f57c7aeb8af 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -1,4 +1,5 @@ """Support for BSH Home Connect appliances.""" +from __future__ import annotations from datetime import timedelta import logging @@ -11,14 +12,39 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DEVICE, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import ( + config_entry_oauth2_flow, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import api -from .const import DOMAIN +from .const import ( + ATTR_KEY, + ATTR_PROGRAM, + ATTR_UNIT, + ATTR_VALUE, + BSH_PAUSE, + BSH_RESUME, + DOMAIN, + SERVICE_OPTION_ACTIVE, + SERVICE_OPTION_SELECTED, + SERVICE_PAUSE_PROGRAM, + SERVICE_RESUME_PROGRAM, + SERVICE_SELECT_PROGRAM, + SERVICE_SETTING, + SERVICE_START_PROGRAM, +) _LOGGER = logging.getLogger(__name__) @@ -39,9 +65,55 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +SERVICE_SETTING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + } +) + +SERVICE_OPTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + vol.Optional(ATTR_UNIT): str, + } +) + +SERVICE_PROGRAM_SCHEMA = vol.Any( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): str, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): vol.Any(int, str), + vol.Optional(ATTR_UNIT): str, + }, + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): str, + }, +) + +SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) + PLATFORMS = [Platform.BINARY_SENSOR, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +def _get_appliance_by_device_id( + hass: HomeAssistant, device_id: str +) -> api.HomeConnectDevice | None: + """Return a Home Connect appliance instance given an device_id.""" + for hc_api in hass.data[DOMAIN].values(): + for dev_dict in hc_api.devices: + device = dev_dict[CONF_DEVICE] + if device.device_id == device_id: + return device.appliance + _LOGGER.error("Appliance for device id %s not found", device_id) + return None + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" hass.data[DOMAIN] = {} @@ -65,6 +137,121 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "configuration.yaml file" ) + async def _async_service_program(call, method): + """Execute calls to services taking a program.""" + program = call.data[ATTR_PROGRAM] + device_id = call.data[ATTR_DEVICE_ID] + options = { + ATTR_KEY: call.data.get(ATTR_KEY), + ATTR_VALUE: call.data.get(ATTR_VALUE), + ATTR_UNIT: call.data.get(ATTR_UNIT), + } + + appliance = _get_appliance_by_device_id(hass, device_id) + if appliance is not None: + await hass.async_add_executor_job( + getattr(appliance, method), program, options + ) + + async def _async_service_command(call, command): + """Execute calls to services executing a command.""" + device_id = call.data[ATTR_DEVICE_ID] + + appliance = _get_appliance_by_device_id(hass, device_id) + if appliance is not None: + await hass.async_add_executor_job(appliance.execute_command, command) + + async def _async_service_key_value(call, method): + """Execute calls to services taking a key and value.""" + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + unit = call.data.get(ATTR_UNIT) + device_id = call.data[ATTR_DEVICE_ID] + + appliance = _get_appliance_by_device_id(hass, device_id) + if appliance is not None: + if unit is not None: + await hass.async_add_executor_job( + getattr(appliance, method), + key, + value, + unit, + ) + else: + await hass.async_add_executor_job( + getattr(appliance, method), + key, + value, + ) + + async def async_service_option_active(call): + """Service for setting an option for an active program.""" + await _async_service_key_value(call, "set_options_active_program") + + async def async_service_option_selected(call): + """Service for setting an option for a selected program.""" + await _async_service_key_value(call, "set_options_selected_program") + + async def async_service_setting(call): + """Service for changing a setting.""" + await _async_service_key_value(call, "set_setting") + + async def async_service_pause_program(call): + """Service for pausing a program.""" + await _async_service_command(call, BSH_PAUSE) + + async def async_service_resume_program(call): + """Service for resuming a paused program.""" + await _async_service_command(call, BSH_RESUME) + + async def async_service_select_program(call): + """Service for selecting a program.""" + await _async_service_program(call, "select_program") + + async def async_service_start_program(call): + """Service for starting a program.""" + await _async_service_program(call, "start_program") + + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_ACTIVE, + async_service_option_active, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_SELECTED, + async_service_option_selected, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_PAUSE_PROGRAM, + async_service_pause_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + async_service_resume_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SELECT_PROGRAM, + async_service_select_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_PROGRAM, + async_service_start_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + return True @@ -101,9 +288,23 @@ async def update_all_devices(hass, entry): """Update all the devices.""" data = hass.data[DOMAIN] hc_api = data[entry.entry_id] + + device_registry = dr.async_get(hass) try: await hass.async_add_executor_job(hc_api.get_devices) for device_dict in hc_api.devices: - await hass.async_add_executor_job(device_dict["device"].initialize) + device = device_dict["device"] + + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.appliance.haId)}, + name=device.appliance.name, + manufacturer=device.appliance.brand, + model=device.appliance.vib, + ) + + device.device_id = device_entry.id + + await hass.async_add_executor_job(device.initialize) except HTTPError as err: _LOGGER.warning("Cannot update devices: %s", err.response.status_code) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index f3c98e618b8..00d759b47d5 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -113,6 +113,7 @@ class HomeConnectDevice: """Initialize the device class.""" self.hass = hass self.appliance = appliance + self.entities = [] def initialize(self): """Fetch the info needed to initialize the device.""" diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 438ee5ace16..9eabc9b5d43 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -30,12 +30,24 @@ BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" +BSH_PAUSE = "BSH.Common.Command.PauseProgram" +BSH_RESUME = "BSH.Common.Command.ResumeProgram" + SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" +SERVICE_OPTION_ACTIVE = "set_option_active" +SERVICE_OPTION_SELECTED = "set_option_selected" +SERVICE_PAUSE_PROGRAM = "pause_program" +SERVICE_RESUME_PROGRAM = "resume_program" +SERVICE_SELECT_PROGRAM = "select_program" +SERVICE_SETTING = "change_setting" +SERVICE_START_PROGRAM = "start_program" + ATTR_AMBIENT = "ambient" ATTR_DESC = "desc" ATTR_DEVICE = "device" ATTR_KEY = "key" +ATTR_PROGRAM = "program" ATTR_SENSOR_TYPE = "sensor_type" ATTR_SIGN = "sign" ATTR_UNIT = "unit" diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index b27988f997d..60a0c3974cd 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -20,6 +20,7 @@ class HomeConnectEntity(Entity): self.device = device self.desc = desc self._name = f"{self.device.appliance.name} {desc}" + self.device.entities.append(self) async def async_added_to_hass(self): """Register callbacks.""" diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 0a055c971c5..ca6e0f012ac 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "dependencies": ["application_credentials"], "codeowners": ["@DavidMStraub"], - "requirements": ["homeconnect==0.7.0"], + "requirements": ["homeconnect==0.7.1"], "config_flow": true, "iot_class": "cloud_push", "loggers": ["homeconnect"] diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml new file mode 100644 index 00000000000..06a646dd481 --- /dev/null +++ b/homeassistant/components/home_connect/services.yaml @@ -0,0 +1,169 @@ +start_program: + name: Start program + description: Selects a program and starts it. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + program: + name: Program + description: Program to select + example: "Dishcare.Dishwasher.Program.Auto2" + required: true + selector: + text: + key: + name: Option key + description: Key of the option. + example: "BSH.Common.Option.StartInRelative" + selector: + text: + value: + name: Option value + description: Value of the option. + example: 1800 + selector: + object: + unit: + name: Option unit + description: Unit for the option. + example: "seconds" + selector: + text: +select_program: + name: Select program + description: Selects a program without starting it. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + program: + name: Program + description: Program to select + example: "Dishcare.Dishwasher.Program.Auto2" + required: true + selector: + text: + key: + name: Option key + description: Key of the option. + example: "BSH.Common.Option.StartInRelative" + selector: + text: + value: + name: Option value + description: Value of the option. + example: 1800 + selector: + object: + unit: + name: Option unit + description: Unit for the option. + example: "seconds" + selector: + text: +pause_program: + name: Pause program + description: Pauses the current running program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect +resume_program: + name: Resume program + description: Resumes a paused program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect +set_option_active: + name: Set active program option + description: Sets an option for the active program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + key: + name: Key + description: Key of the option. + example: "LaundryCare.Dryer.Option.DryingTarget" + required: true + selector: + text: + value: + name: Value + description: Value of the option. + example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" + required: true + selector: + object: +set_option_selected: + name: Set selected program option + description: Sets an option for the selected program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + key: + name: Key + description: Key of the option. + example: "LaundryCare.Dryer.Option.DryingTarget" + required: true + selector: + text: + value: + name: Value + description: Value of the option. + example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" + required: true + selector: + object: +change_setting: + name: Change setting + description: Changes a setting. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + key: + name: Key + description: Key of the setting. + example: "BSH.Common.Setting.ChildLock" + required: true + selector: + text: + value: + name: Value + description: Value of the setting. + example: "true" + required: true + selector: + object: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 43180b237b9..317e9d1dfcd 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -2,15 +2,16 @@ "system_health": { "info": { "arch": "CPU Architecture", + "config_dir": "Configuration Directory", "dev": "Development", "docker": "Docker", - "user": "User", "hassio": "Supervisor", "installation_type": "Installation Type", "os_name": "Operating System Family", "os_version": "Operating System Version", "python_version": "Python Version", "timezone": "Timezone", + "user": "User", "version": "Version", "virtualenv": "Virtual Environment" } diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index f13278ddfeb..4006228de25 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -29,4 +29,5 @@ async def system_health_info(hass): "os_version": info.get("os_version"), "arch": info.get("arch"), "timezone": info.get("timezone"), + "config_dir": hass.config.config_dir, } diff --git a/homeassistant/components/homeassistant/translations/en.json b/homeassistant/components/homeassistant/translations/en.json index 977bc203fea..37c4498b32b 100644 --- a/homeassistant/components/homeassistant/translations/en.json +++ b/homeassistant/components/homeassistant/translations/en.json @@ -2,6 +2,7 @@ "system_health": { "info": { "arch": "CPU Architecture", + "config_dir": "Configuration Directory", "dev": "Development", "docker": "Docker", "hassio": "Supervisor", diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py new file mode 100644 index 00000000000..e6eaa2f7fce --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -0,0 +1,36 @@ +"""The Home Assistant Yellow integration.""" +from __future__ import annotations + +from homeassistant.components.hassio import get_os_info +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Home Assistant Yellow config entry.""" + if (os_info := get_os_info(hass)) is None: + # The hassio integration has not yet fetched data from the supervisor + raise ConfigEntryNotReady + + board: str | None + if (board := os_info.get("board")) is None or not board == "yellow": + # Not running on a Home Assistant Yellow, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + await hass.config_entries.flow.async_init( + "zha", + context={"source": "hardware"}, + data={ + "name": "Yellow", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "efr32", + }, + ) + + return True diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py new file mode 100644 index 00000000000..191a28f47a4 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for the Home Assistant Yellow integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Yellow.""" + + VERSION = 1 + + 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 Yellow", data={}) diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py new file mode 100644 index 00000000000..41eae70b3f2 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -0,0 +1,3 @@ +"""Constants for the Home Assistant Yellow integration.""" + +DOMAIN = "homeassistant_yellow" diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py new file mode 100644 index 00000000000..aa1fe4b745b --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/hardware.py @@ -0,0 +1,34 @@ +"""The Home Assistant Yellow hardware platform.""" +from __future__ import annotations + +from homeassistant.components.hardware.models import BoardInfo, HardwareInfo +from homeassistant.components.hassio import get_os_info +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +BOARD_NAME = "Home Assistant Yellow" +MANUFACTURER = "homeassistant" +MODEL = "yellow" + + +@callback +def async_info(hass: HomeAssistant) -> HardwareInfo: + """Return board info.""" + if (os_info := get_os_info(hass)) is None: + raise HomeAssistantError + board: str | None + if (board := os_info.get("board")) is None: + raise HomeAssistantError + if not board == "yellow": + raise HomeAssistantError + + return HardwareInfo( + board=BoardInfo( + hassio_board_id=board, + manufacturer=MANUFACTURER, + model=MODEL, + revision=None, + ), + name=BOARD_NAME, + url=None, + ) diff --git a/homeassistant/components/homeassistant_yellow/manifest.json b/homeassistant/components/homeassistant_yellow/manifest.json new file mode 100644 index 00000000000..47e6c8e2cd8 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "homeassistant_yellow", + "name": "Home Assistant Yellow", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", + "dependencies": ["hardware", "hassio"], + "codeowners": ["@home-assistant/core"], + "integration_type": "hardware" +} diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index bde540d6372..2ca78b6d915 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==4.4.0", + "HAP-python==4.5.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/homeassistant/components/homekit/translations/es.json b/homeassistant/components/homekit/translations/es.json index 9aa71faef12..5076e7b8800 100644 --- a/homeassistant/components/homekit/translations/es.json +++ b/homeassistant/components/homekit/translations/es.json @@ -22,7 +22,8 @@ "accessory": { "data": { "entities": "Entidad" - } + }, + "title": "Seleccione la entidad para el accesorio" }, "advanced": { "data": { @@ -43,16 +44,20 @@ "data": { "entities": "Entidades" }, + "description": "Se incluir\u00e1n todas las entidades de \" {domains} \" excepto las entidades excluidas y las entidades categorizadas.", "title": "Selecciona las entidades a excluir" }, "include": { "data": { "entities": "Entidades" - } + }, + "description": "Se incluir\u00e1n todas las entidades de \" {domains} \" a menos que se seleccionen entidades espec\u00edficas.", + "title": "Seleccione las entidades a incluir" }, "init": { "data": { "domains": "Dominios a incluir", + "include_exclude_mode": "Modo de inclusi\u00f3n", "mode": "Mode de HomeKit" }, "description": "Las entidades de los \"Dominios que se van a incluir\" se establecer\u00e1n en HomeKit. Podr\u00e1 seleccionar qu\u00e9 entidades excluir de esta lista en la siguiente pantalla.", diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 3a09e3a48ff..e612c8248be 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -14,7 +14,7 @@ from pyhap.const import CATEGORY_CAMERA from homeassistant.components import camera from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import STATE_ON -from homeassistant.core import callback +from homeassistant.core import Event, callback from homeassistant.helpers.event import ( async_track_state_change_event, async_track_time_interval, @@ -56,7 +56,7 @@ from .const import ( SERV_SPEAKER, SERV_STATELESS_PROGRAMMABLE_SWITCH, ) -from .util import pid_is_alive +from .util import pid_is_alive, state_changed_event_is_same_state _LOGGER = logging.getLogger(__name__) @@ -265,9 +265,10 @@ class Camera(HomeAccessory, PyhapCamera): await super().run() @callback - def _async_update_motion_state_event(self, event): + def _async_update_motion_state_event(self, event: Event) -> None: """Handle state change event listener callback.""" - self._async_update_motion_state(event.data.get("new_state")) + if not state_changed_event_is_same_state(event): + self._async_update_motion_state(event.data.get("new_state")) @callback def _async_update_motion_state(self, new_state): @@ -288,9 +289,10 @@ class Camera(HomeAccessory, PyhapCamera): ) @callback - def _async_update_doorbell_state_event(self, event): + def _async_update_doorbell_state_event(self, event: Event) -> None: """Handle state change event listener callback.""" - self._async_update_doorbell_state(event.data.get("new_state")) + if not state_changed_event_is_same_state(event): + self._async_update_doorbell_state(event.data.get("new_state")) @callback def _async_update_doorbell_state(self, new_state): diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index af7501e1869..18dfe48b2bd 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -1,5 +1,6 @@ """Class to hold all lock accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_DOOR_LOCK @@ -12,7 +13,7 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, ) from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK @@ -59,11 +60,12 @@ class Lock(HomeAccessory): The lock entity must support: unlock and lock. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Lock accessory object.""" super().__init__(*args, category=CATEGORY_DOOR_LOCK) self._code = self.config.get(ATTR_CODE) state = self.hass.states.get(self.entity_id) + assert state is not None serv_lock_mechanism = self.add_preload_service(SERV_LOCK) self.char_current_state = serv_lock_mechanism.configure_char( @@ -76,7 +78,7 @@ class Lock(HomeAccessory): ) self.async_update_state(state) - def set_state(self, value): + def set_state(self, value: int) -> None: """Set lock state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) @@ -89,7 +91,7 @@ class Lock(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update lock after state changed.""" hass_state = new_state.state current_lock_state = HASS_TO_HOMEKIT_CURRENT.get( diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 8b010f85fb6..34df1008e76 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -37,7 +37,7 @@ from homeassistant.const import ( CONF_TYPE, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR import homeassistant.util.temperature as temp_util @@ -572,3 +572,11 @@ def state_needs_accessory_mode(state: State) -> bool: and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY ) + + +def state_changed_event_is_same_state(event: Event) -> bool: + """Check if a state changed event is the same state.""" + event_data = event.data + old_state: State | None = event_data.get("old_state") + new_state: State | None = event_data.get("new_state") + return bool(new_state and old_state and new_state.state == old_state.state) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 6b538658b23..6909b226556 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -15,9 +15,10 @@ from aiohomekit.model.characteristics import ( from aiohomekit.model.services import Service, ServicesTypes from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ATTR_IDENTIFIERS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType @@ -261,3 +262,18 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: "HomeKit again", entry.title, ) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove homekit_controller config entry from a device.""" + hkid = config_entry.data["AccessoryPairingID"] + connection: HKDevice = hass.data[KNOWN_DEVICES][hkid] + return not device_entry.identifiers.intersection( + identifier + for accessory in connection.entity_map.accessories + for identifier in connection.device_info_for_accessory(accessory)[ + ATTR_IDENTIFIERS + ] + ) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index ae9b1261bc8..955f5e37177 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.7.17"], + "requirements": ["aiohomekit==0.7.20"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index b994bc80f4a..4e0f5cfa077 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -104,26 +104,26 @@ class HomeKitNumber(CharacteristicEntity, NumberEntity): return [self._char.type] @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return self._char.minValue or DEFAULT_MIN_VALUE @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return self._char.maxValue or DEFAULT_MAX_VALUE @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return self._char.minStep or DEFAULT_STEP @property - def value(self) -> float: + def native_value(self) -> float: """Return the current characteristic value.""" return self._char.value - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the characteristic to this value.""" await self.async_put_characteristics( { @@ -148,26 +148,26 @@ class HomeKitEcobeeFanModeNumber(CharacteristicEntity, NumberEntity): return f"{prefix} Fan Mode" @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return self._char.minValue or DEFAULT_MIN_VALUE @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return self._char.maxValue or DEFAULT_MAX_VALUE @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return self._char.minStep or DEFAULT_STEP @property - def value(self) -> float: + def native_value(self) -> float: """Return the current characteristic value.""" return self._char.value - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the characteristic to this value.""" # Sending the fan mode request sometimes ends up getting ignored by ecobee diff --git a/homeassistant/components/homekit_controller/translations/select.es.json b/homeassistant/components/homekit_controller/translations/select.es.json index 0cbfbc71373..13c45f8e538 100644 --- a/homeassistant/components/homekit_controller/translations/select.es.json +++ b/homeassistant/components/homekit_controller/translations/select.es.json @@ -2,6 +2,7 @@ "state": { "homekit_controller__ecobee_mode": { "away": "Afuera", + "home": "Inicio", "sleep": "Durmiendo" } } diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index 2aa47ad863a..d5f1802e774 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -1,6 +1,8 @@ """Support for HomeMatic covers.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -41,7 +43,7 @@ class HMCover(HMDevice, CoverEntity): """Representation a HomeMatic Cover.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """ Return current position of cover. @@ -49,7 +51,7 @@ class HMCover(HMDevice, CoverEntity): """ return int(self._hm_get_state() * 100) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = float(kwargs[ATTR_POSITION]) @@ -58,21 +60,21 @@ class HMCover(HMDevice, CoverEntity): self._hmdevice.set_level(level, self._channel) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return whether the cover is closed.""" if self.current_cover_position is not None: return self.current_cover_position == 0 return None - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._hmdevice.move_up(self._channel) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._hmdevice.move_down(self._channel) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" self._hmdevice.stop(self._channel) @@ -84,7 +86,7 @@ class HMCover(HMDevice, CoverEntity): self._data.update({"LEVEL_2": None}) @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. None is unknown, 0 is closed, 100 is fully open. @@ -93,7 +95,7 @@ class HMCover(HMDevice, CoverEntity): return None return int(position * 100) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" if "LEVEL_2" in self._data and ATTR_TILT_POSITION in kwargs: position = float(kwargs[ATTR_TILT_POSITION]) @@ -101,17 +103,17 @@ class HMCover(HMDevice, CoverEntity): level = position / 100.0 self._hmdevice.set_cover_tilt_position(level, self._channel) - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" if "LEVEL_2" in self._data: self._hmdevice.open_slats() - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" if "LEVEL_2" in self._data: self._hmdevice.close_slats() - def stop_cover_tilt(self, **kwargs): + def stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" if "LEVEL_2" in self._data: self.stop_cover(**kwargs) @@ -123,7 +125,7 @@ class HMGarage(HMCover): _attr_device_class = CoverDeviceClass.GARAGE @property - def current_cover_position(self): + def current_cover_position(self) -> None: """ Return current position of cover. @@ -133,7 +135,7 @@ class HMGarage(HMCover): return None @property - def is_closed(self): + def is_closed(self) -> bool: """Return whether the cover is closed.""" return self._hmdevice.is_closed(self._hm_get_state()) diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py index 32ee4698736..abca46ddf58 100644 --- a/homeassistant/components/homematic/lock.py +++ b/homeassistant/components/homematic/lock.py @@ -1,6 +1,8 @@ """Support for Homematic locks.""" from __future__ import annotations +from typing import Any + from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,19 +35,19 @@ class HMLock(HMDevice, LockEntity): _attr_supported_features = LockEntityFeature.OPEN @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if the lock is locked.""" return not bool(self._hm_get_state()) - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" self._hmdevice.lock() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" self._hmdevice.unlock() - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Open the door latch.""" self._hmdevice.open() diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 86e187410b3..3b6ae684d07 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -83,15 +83,15 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): def _security_and_alarm(self) -> SecurityAndAlarmHome: return self._home.get_functionalHome(SecurityAndAlarmHome) - async def async_alarm_disarm(self, code=None) -> None: + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._home.set_security_zones_activation(False, False) - async def async_alarm_arm_home(self, code=None) -> None: + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._home.set_security_zones_activation(False, True) - async def async_alarm_arm_away(self, code=None) -> None: + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._home.set_security_zones_activation(True, True) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index f4af4d88a8e..31faea875a4 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -1,6 +1,8 @@ """Support for HomematicIP Cloud cover devices.""" from __future__ import annotations +from typing import Any + from homematicip.aio.device import ( AsyncBlindModule, AsyncDinRailBlind4, @@ -86,14 +88,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): return int((1 - self._device.secondaryShadingLevel) * 100) return None - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 await self._device.set_primary_shading_level(primaryShadingLevel=level) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 @@ -110,37 +112,37 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): return self._device.primaryShadingLevel == HMIP_COVER_CLOSED return None - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.set_primary_shading_level( primaryShadingLevel=HMIP_COVER_OPEN ) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.set_primary_shading_level( primaryShadingLevel=HMIP_COVER_CLOSED ) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.stop() - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" await self._device.set_secondary_shading_level( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_OPEN, ) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" await self._device.set_secondary_shading_level( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_CLOSED, ) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.stop() @@ -174,7 +176,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): ) return None - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 @@ -191,15 +193,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): ) return None - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.set_shutter_level(HMIP_COVER_OPEN, self._channel) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.set_shutter_level(HMIP_COVER_CLOSED, self._channel) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.set_shutter_stop(self._channel) @@ -236,22 +238,26 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): ) return None - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(level, self._channel) + await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel) - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_slats_level(HMIP_SLATS_OPEN, self._channel) + await self._device.set_slats_level( + slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel + ) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_slats_level(HMIP_SLATS_CLOSED, self._channel) + await self._device.set_slats_level( + slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel + ) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.set_shutter_stop(self._channel) @@ -288,15 +294,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Return if the cover is closed.""" return self._device.doorState == DoorState.CLOSED - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.send_door_command(DoorCommand.OPEN) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.send_door_command(DoorCommand.CLOSE) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._device.send_door_command(DoorCommand.STOP) @@ -335,40 +341,40 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): return self._device.shutterLevel == HMIP_COVER_CLOSED return None - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 await self._device.set_shutter_level(level) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 await self._device.set_slats_level(level) - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.set_shutter_level(HMIP_COVER_OPEN) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.set_shutter_level(HMIP_COVER_CLOSED) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the group if in motion.""" await self._device.set_shutter_stop() - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" await self._device.set_slats_level(HMIP_SLATS_OPEN) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" await self._device.set_slats_level(HMIP_SLATS_CLOSED) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the group if in motion.""" await self._device.set_shutter_stop() diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index b13c8ca19b2..40f7e67fd07 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -3,7 +3,7 @@ "name": "HomematicIP Cloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", - "requirements": ["homematicip==1.0.2"], + "requirements": ["homematicip==1.0.3"], "codeowners": [], "quality_scale": "platinum", "iot_class": "cloud_push", diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index df883baf3b1..6f164637a7c 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -2,10 +2,10 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError, UnsupportedError +from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError from voluptuous import Required, Schema from homeassistant import config_entries @@ -160,9 +160,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", description_placeholders={ - CONF_PRODUCT_TYPE: self.config[CONF_PRODUCT_TYPE], - CONF_SERIAL: self.config[CONF_SERIAL], - CONF_IP_ADDRESS: self.config[CONF_IP_ADDRESS], + CONF_PRODUCT_TYPE: cast(str, self.config[CONF_PRODUCT_TYPE]), + CONF_SERIAL: cast(str, self.config[CONF_SERIAL]), + CONF_IP_ADDRESS: cast(str, self.config[CONF_IP_ADDRESS]), }, ) @@ -187,6 +187,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.error("API version unsuppored") raise AbortFlow("unsupported_api_version") from ex + except RequestError as ex: + _LOGGER.error("Unexpected or no response") + raise AbortFlow("unknown_error") from ex + except Exception as ex: _LOGGER.exception( "Error connecting with Energy Device at %s", diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index e12edda63ae..bab7b5d3ba3 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError +from homewizard_energy.errors import DisabledError, RequestError from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -41,6 +41,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] "state": await self.api.state(), } + except RequestError as ex: + raise UpdateFailed("Device did not respond as expected") from ex + except DisabledError as ex: raise UpdateFailed("API disabled, API must be enabled in the app") from ex diff --git a/homeassistant/components/homewizard/translations/sv.json b/homeassistant/components/homewizard/translations/sv.json new file mode 100644 index 00000000000..c9bc9cd66a9 --- /dev/null +++ b/homeassistant/components/homewizard/translations/sv.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Konfigurera enhet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index e6fdd9b54bd..7f7d7d7281a 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure the honeywell integration.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -52,7 +54,9 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> HoneywellOptionsFlowHandler: """Options callback for Honeywell.""" return HoneywellOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json index f30e9606a4d..98ad6871b81 100644 --- a/homeassistant/components/honeywell/translations/es.json +++ b/homeassistant/components/honeywell/translations/es.json @@ -17,8 +17,10 @@ "step": { "init": { "data": { - "away_cool_temperature": "Temperatura fria, modo fuera" - } + "away_cool_temperature": "Temperatura fria, modo fuera", + "away_heat_temperature": "Temperatura del calor exterior" + }, + "description": "Opciones de configuraci\u00f3n adicionales de Honeywell. Las temperaturas se establecen en Fahrenheit." } } } diff --git a/homeassistant/components/honeywell/translations/sv.json b/homeassistant/components/honeywell/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/honeywell/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index dab6abede4c..09ef6e13e03 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -7,11 +7,11 @@ from ipaddress import ip_address import logging import secrets from typing import Final -from urllib.parse import unquote from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from yarl import URL from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User @@ -29,6 +29,7 @@ _LOGGER = logging.getLogger(__name__) DATA_API_PASSWORD: Final = "api_password" DATA_SIGN_SECRET: Final = "http.auth.sign_secret" SIGN_QUERY_PARAM: Final = "authSig" +SAFE_QUERY_PARAMS: Final = ["height", "width"] STORAGE_VERSION = 1 STORAGE_KEY = "http.auth" @@ -57,18 +58,26 @@ def async_sign_path( else: refresh_token_id = hass.data[STORAGE_KEY] + url = URL(path) now = dt_util.utcnow() + params = dict(sorted(url.query.items())) + for param in SAFE_QUERY_PARAMS: + params.pop(param, None) encoded = jwt.encode( { "iss": refresh_token_id, - "path": unquote(path), + "path": url.path, + "params": params, "iat": now, "exp": now + expiration, }, secret, algorithm="HS256", ) - return f"{path}?{SIGN_QUERY_PARAM}={encoded}" + + params[SIGN_QUERY_PARAM] = encoded + url = url.with_query(params) + return f"{url.path}?{url.query_string}" @callback @@ -176,6 +185,13 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False + params = dict(sorted(request.query.items())) + del params[SIGN_QUERY_PARAM] + for param in SAFE_QUERY_PARAMS: + params.pop(param, None) + if claims["params"] != params: + return False + refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) if refresh_token is None: diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 192d2d5d57b..6ab3b2a84a4 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from http import HTTPStatus -import json import logging from typing import Any @@ -21,7 +20,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, is_callback -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS, json_bytes from .const import KEY_AUTHENTICATED, KEY_HASS @@ -53,8 +52,8 @@ class HomeAssistantView: ) -> web.Response: """Return a JSON response.""" try: - msg = json.dumps(result, cls=JSONEncoder, allow_nan=False).encode("UTF-8") - except (ValueError, TypeError) as err: + msg = json_bytes(result) + except JSON_ENCODE_EXCEPTIONS as err: _LOGGER.error("Unable to serialize to JSON: %s\n%s", err, result) raise HTTPInternalServerError from err response = web.Response( diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 601a3b9af8d..f4e2cb209db 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -9,6 +9,7 @@ from datetime import timedelta import logging import time from typing import Any, NamedTuple, cast +from xml.parsers.expat import ExpatError from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection @@ -204,14 +205,13 @@ class Router: "%s requires authorization, excluding from future updates", key ) self.subscriptions.pop(key) - except ResponseErrorException as exc: + except (ResponseErrorException, ExpatError) as exc: + # Take ResponseErrorNotSupportedException, ExpatError, and generic + # ResponseErrorException with a few select codes to mean the endpoint is + # not supported. if not isinstance( - exc, ResponseErrorNotSupportedException - ) and exc.code not in ( - # additional codes treated as unusupported - -1, - 100006, - ): + exc, (ResponseErrorNotSupportedException, ExpatError) + ) and exc.code not in (-1, 100006): raise _LOGGER.info( "%s apparently not supported by device, excluding from future updates", diff --git a/homeassistant/components/hue/translations/no.json b/homeassistant/components/hue/translations/no.json index 9c18003aca0..d34899db978 100644 --- a/homeassistant/components/hue/translations/no.json +++ b/homeassistant/components/hue/translations/no.json @@ -5,7 +5,7 @@ "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "cannot_connect": "Tilkobling mislyktes", - "discover_timeout": "Kunne ikke oppdage Hue Bridger", + "discover_timeout": "Fant ingen Hue Bridger", "invalid_host": "Ugyldig vert", "no_bridges": "Ingen Philips Hue Bridger oppdaget", "not_hue_bridge": "Ikke en Hue bro", diff --git a/homeassistant/components/hue/translations/zh-Hans.json b/homeassistant/components/hue/translations/zh-Hans.json index b883262a8d6..d7ec6ebaeb4 100644 --- a/homeassistant/components/hue/translations/zh-Hans.json +++ b/homeassistant/components/hue/translations/zh-Hans.json @@ -32,19 +32,20 @@ "2": "\u7b2c\u4e8c\u952e", "3": "\u7b2c\u4e09\u952e", "4": "\u7b2c\u56db\u952e", - "turn_off": "\u5173\u95ed" + "turn_off": "\u5173\u95ed", + "turn_on": "\u6253\u5f00" }, "trigger_type": { - "double_short_release": "\u201c{subtype}\u201d\u4e24\u952e\u540c\u65f6\u677e\u5f00", - "initial_press": "\u201c{subtype}\u201d\u9996\u6b21\u6309\u4e0b", - "long_release": "\u201c{subtype}\u201d\u957f\u6309\u540e\u677e\u5f00", + "double_short_release": "\"{subtype}\" \u4e24\u952e\u540c\u65f6\u677e\u5f00", + "initial_press": "\"{subtype}\" \u9996\u6b21\u6309\u4e0b", + "long_release": "\"{subtype}\" \u957f\u6309\u540e\u677e\u5f00", "remote_button_long_release": "\"{subtype}\" \u957f\u6309\u540e\u677e\u5f00", "remote_button_short_press": "\"{subtype}\" \u5355\u51fb", "remote_button_short_release": "\"{subtype}\" \u677e\u5f00", "remote_double_button_long_press": "\"{subtype}\" \u4e24\u952e\u540c\u65f6\u957f\u6309\u540e\u677e\u5f00", "remote_double_button_short_press": "\"{subtype}\" \u4e24\u952e\u540c\u65f6\u677e\u5f00", - "repeat": "\u201c{subtype}\u201d\u6309\u4f4f\u4e0d\u653e", - "short_release": "\u201c{subtype}\u201d\u77ed\u6309\u540e\u677e\u5f00" + "repeat": "\"{subtype}\" \u6309\u4f4f\u4e0d\u653e", + "short_release": "\"{subtype}\" \u77ed\u6309\u540e\u677e\u5f00" } }, "options": { diff --git a/homeassistant/components/huisbaasje/translations/sv.json b/homeassistant/components/huisbaasje/translations/sv.json index d52e8b8362c..4a6100815d6 100644 --- a/homeassistant/components/huisbaasje/translations/sv.json +++ b/homeassistant/components/huisbaasje/translations/sv.json @@ -2,6 +2,13 @@ "config": { "error": { "cannot_connect": "Kunde inte ansluta" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/he.json b/homeassistant/components/humidifier/translations/he.json index 5a4c58c7934..4cd7b4d8196 100644 --- a/homeassistant/components/humidifier/translations/he.json +++ b/homeassistant/components/humidifier/translations/he.json @@ -2,7 +2,7 @@ "device_automation": { "action_type": { "set_mode": "\u05e9\u05e0\u05d4 \u05de\u05e6\u05d1 \u05d1-{entity_name}", - "toggle": "\u05d4\u05d7\u05dc\u05e3 \u05d0\u05ea {entity_name}", + "toggle": "\u05d4\u05d7\u05dc\u05e4\u05ea {entity_name}", "turn_off": "\u05db\u05d1\u05d4 \u05d0\u05ea {entity_name}", "turn_on": "\u05d4\u05e4\u05e2\u05dc \u05d0\u05ea {entity_name}" }, diff --git a/homeassistant/components/humidifier/translations/sv.json b/homeassistant/components/humidifier/translations/sv.json index 325e9f2e6a0..2818d7b7b04 100644 --- a/homeassistant/components/humidifier/translations/sv.json +++ b/homeassistant/components/humidifier/translations/sv.json @@ -3,5 +3,10 @@ "trigger_type": { "turned_off": "{entity_name} st\u00e4ngdes av" } + }, + "state": { + "_": { + "on": "P\u00e5" + } } } \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/zh-Hans.json b/homeassistant/components/humidifier/translations/zh-Hans.json index d21c7bf61f7..230afd3e4ab 100644 --- a/homeassistant/components/humidifier/translations/zh-Hans.json +++ b/homeassistant/components/humidifier/translations/zh-Hans.json @@ -13,6 +13,7 @@ "is_on": "{entity_name} \u5df2\u6253\u5f00" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "target_humidity_changed": "{entity_name} \u7684\u8bbe\u5b9a\u6e7f\u5ea6\u53d8\u5316", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 17dd580f5cc..4a22bc4ed81 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -1,10 +1,8 @@ """The Hunter Douglas PowerView integration.""" -from datetime import timedelta import logging from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.api_base import ApiEntryPoint -from aiopvapi.helpers.constants import ATTR_ID from aiopvapi.helpers.tools import base64_to_unicode from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes @@ -14,48 +12,37 @@ import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( API_PATH_FWVERSION, - COORDINATOR, DEFAULT_LEGACY_MAINPROCESSOR, - DEVICE_FIRMWARE, - DEVICE_INFO, - DEVICE_MAC_ADDRESS, - DEVICE_MODEL, - DEVICE_NAME, - DEVICE_REVISION, - DEVICE_SERIAL_NUMBER, DOMAIN, FIRMWARE, FIRMWARE_MAINPROCESSOR, FIRMWARE_NAME, - FIRMWARE_REVISION, HUB_EXCEPTIONS, HUB_NAME, MAC_ADDRESS_IN_USERDATA, - PV_API, - PV_ROOM_DATA, - PV_SCENE_DATA, - PV_SHADE_DATA, - PV_SHADES, ROOM_DATA, SCENE_DATA, SERIAL_NUMBER_IN_USERDATA, SHADE_DATA, USER_DATA, ) +from .coordinator import PowerviewShadeUpdateCoordinator +from .model import PowerviewDeviceInfo, PowerviewEntryData +from .shade_data import PowerviewShadeData +from .util import async_map_data_by_id PARALLEL_UPDATES = 1 CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -PLATFORMS = [Platform.COVER, Platform.SCENE, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SCENE, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -64,30 +51,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config = entry.data - hub_address = config.get(CONF_HOST) + hub_address = config[CONF_HOST] websession = async_get_clientsession(hass) pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) try: async with async_timeout.timeout(10): - device_info = await async_get_device_info(pv_request) + device_info = await async_get_device_info(pv_request, hub_address) async with async_timeout.timeout(10): rooms = Rooms(pv_request) - room_data = _async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) + room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) async with async_timeout.timeout(10): scenes = Scenes(pv_request) - scene_data = _async_map_data_by_id( + scene_data = async_map_data_by_id( (await scenes.get_resources())[SCENE_DATA] ) async with async_timeout.timeout(10): shades = Shades(pv_request) - shade_data = _async_map_data_by_id( - (await shades.get_resources())[SHADE_DATA] - ) + shade_entries = await shades.get_resources() + shade_data = async_map_data_by_id(shade_entries[SHADE_DATA]) + except HUB_EXCEPTIONS as err: raise ConfigEntryNotReady( f"Connection error to PowerView hub: {hub_address}: {err}" @@ -95,39 +82,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not device_info: raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") - async def async_update_data(): - """Fetch data from shade endpoint.""" - async with async_timeout.timeout(10): - shade_entries = await shades.get_resources() - if not shade_entries: - raise UpdateFailed("Failed to fetch new shade data.") - return _async_map_data_by_id(shade_entries[SHADE_DATA]) + coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) + coordinator.async_set_updated_data(PowerviewShadeData()) + # populate raw shade data into the coordinator for diagnostics + coordinator.data.store_group_data(shade_entries[SHADE_DATA]) - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="powerview hub", - update_method=async_update_data, - update_interval=timedelta(seconds=60), + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = PowerviewEntryData( + api=pv_request, + room_data=room_data, + scene_data=scene_data, + shade_data=shade_data, + coordinator=coordinator, + device_info=device_info, ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - PV_API: pv_request, - PV_ROOM_DATA: room_data, - PV_SCENE_DATA: scene_data, - PV_SHADES: shades, - PV_SHADE_DATA: shade_data, - COORDINATOR: coordinator, - DEVICE_INFO: device_info, - } - hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_get_device_info(pv_request): +async def async_get_device_info( + pv_request: AioRequest, hub_address: str +) -> PowerviewDeviceInfo: """Determine device info.""" userdata = UserData(pv_request) resources = await userdata.get_resources() @@ -145,20 +121,14 @@ async def async_get_device_info(pv_request): else: main_processor_info = DEFAULT_LEGACY_MAINPROCESSOR - return { - DEVICE_NAME: base64_to_unicode(userdata_data[HUB_NAME]), - DEVICE_MAC_ADDRESS: userdata_data[MAC_ADDRESS_IN_USERDATA], - DEVICE_SERIAL_NUMBER: userdata_data[SERIAL_NUMBER_IN_USERDATA], - DEVICE_REVISION: main_processor_info[FIRMWARE_REVISION], - DEVICE_FIRMWARE: main_processor_info, - DEVICE_MODEL: main_processor_info[FIRMWARE_NAME], - } - - -@callback -def _async_map_data_by_id(data): - """Return a dict with the key being the id for a list of entries.""" - return {entry[ATTR_ID]: entry for entry in data} + return PowerviewDeviceInfo( + name=base64_to_unicode(userdata_data[HUB_NAME]), + mac_address=userdata_data[MAC_ADDRESS_IN_USERDATA], + serial_number=userdata_data[SERIAL_NUMBER_IN_USERDATA], + firmware=main_processor_info, + model=main_processor_info[FIRMWARE_NAME], + hub_address=hub_address, + ) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py new file mode 100644 index 00000000000..483e2ca2784 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -0,0 +1,105 @@ +"""Buttons for Hunter Douglas Powerview advanced features.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from aiopvapi.resources.shade import BaseShade, factory as PvShade + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE +from .coordinator import PowerviewShadeUpdateCoordinator +from .entity import ShadeEntity +from .model import PowerviewDeviceInfo, PowerviewEntryData + + +@dataclass +class PowerviewButtonDescriptionMixin: + """Mixin to describe a Button entity.""" + + press_action: Callable[[BaseShade], Any] + + +@dataclass +class PowerviewButtonDescription( + ButtonEntityDescription, PowerviewButtonDescriptionMixin +): + """Class to describe a Button entity.""" + + +BUTTONS: Final = [ + PowerviewButtonDescription( + key="calibrate", + name="Calibrate", + icon="mdi:swap-vertical-circle-outline", + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda shade: shade.calibrate(), + ), + PowerviewButtonDescription( + key="identify", + name="Identify", + icon="mdi:crosshairs-question", + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda shade: shade.jog(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the hunter douglas advanced feature buttons.""" + + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] + + entities: list[ButtonEntity] = [] + for raw_shade in pv_entry.shade_data.values(): + shade: BaseShade = PvShade(raw_shade, pv_entry.api) + name_before_refresh = shade.name + room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) + room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + + for description in BUTTONS: + entities.append( + PowerviewButton( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + name_before_refresh, + description, + ) + ) + + async_add_entities(entities) + + +class PowerviewButton(ShadeEntity, ButtonEntity): + """Representation of an advanced feature button.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + description: PowerviewButtonDescription, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self.entity_description: PowerviewButtonDescription = description + self._attr_name = f"{self._shade_name} {description.name}" + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_action(self._shade) diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index e0ebef7abbb..1666db27d86 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import async_get_device_info -from .const import DEVICE_NAME, DEVICE_SERIAL_NUMBER, DOMAIN, HUB_EXCEPTIONS +from .const import DOMAIN, HUB_EXCEPTIONS _LOGGER = logging.getLogger(__name__) @@ -35,14 +35,14 @@ async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str try: async with async_timeout.timeout(10): - device_info = await async_get_device_info(pv_request) + device_info = await async_get_device_info(pv_request, hub_address) except HUB_EXCEPTIONS as err: raise CannotConnect from err # Return info that you want to store in the config entry. return { - "title": device_info[DEVICE_NAME], - "unique_id": device_info[DEVICE_SERIAL_NUMBER], + "title": device_info.name, + "unique_id": device_info.serial_number, } diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index ea87150a9ca..9d99710f36d 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -7,7 +7,6 @@ from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatu DOMAIN = "hunterdouglas_powerview" - MANUFACTURER = "Hunter Douglas" HUB_ADDRESS = "address" @@ -28,13 +27,9 @@ FIRMWARE_REVISION = "revision" FIRMWARE_SUB_REVISION = "subRevision" FIRMWARE_BUILD = "build" -DEVICE_NAME = "device_name" -DEVICE_MAC_ADDRESS = "device_mac_address" -DEVICE_SERIAL_NUMBER = "device_serial_number" -DEVICE_REVISION = "device_revision" -DEVICE_INFO = "device_info" -DEVICE_MODEL = "device_model" -DEVICE_FIRMWARE = "device_firmware" +REDACT_MAC_ADDRESS = "mac_address" +REDACT_SERIAL_NUMBER = "serial_number" +REDACT_HUB_ADDRESS = "hub_address" SCENE_NAME = "name" SCENE_ID = "id" @@ -48,20 +43,11 @@ ROOM_NAME = "name" ROOM_NAME_UNICODE = "name_unicode" ROOM_ID = "id" -SHADE_RESPONSE = "shade" SHADE_BATTERY_LEVEL = "batteryStrength" SHADE_BATTERY_LEVEL_MAX = 200 STATE_ATTRIBUTE_ROOM_NAME = "roomName" -PV_API = "pv_api" -PV_HUB = "pv_hub" -PV_SHADES = "pv_shades" -PV_SCENE_DATA = "pv_scene_data" -PV_SHADE_DATA = "pv_shade_data" -PV_ROOM_DATA = "pv_room_data" -COORDINATOR = "coordinator" - HUB_EXCEPTIONS = ( ServerDisconnectedError, asyncio.TimeoutError, @@ -81,5 +67,16 @@ DEFAULT_LEGACY_MAINPROCESSOR = { FIRMWARE_NAME: LEGACY_DEVICE_MODEL, } - API_PATH_FWVERSION = "api/fwversion" + +POS_KIND_NONE = 0 +POS_KIND_PRIMARY = 1 +POS_KIND_SECONDARY = 2 +POS_KIND_VANE = 3 +POS_KIND_ERROR = 4 + + +ATTR_BATTERY_KIND = "batteryKind" +BATTERY_KIND_HARDWIRED = 1 +BATTERY_KIND_BATTERY = 2 +BATTERY_KIND_RECHARGABLE = 3 diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py new file mode 100644 index 00000000000..7c45feba491 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -0,0 +1,54 @@ +"""Coordinate data for powerview devices.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiopvapi.shades import Shades +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SHADE_DATA +from .shade_data import PowerviewShadeData + +_LOGGER = logging.getLogger(__name__) + + +class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): + """DataUpdateCoordinator to gather data from a powerview hub.""" + + def __init__( + self, + hass: HomeAssistant, + shades: Shades, + hub_address: str, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + self.shades = shades + super().__init__( + hass, + _LOGGER, + name=f"powerview hub {hub_address}", + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> PowerviewShadeData: + """Fetch data from shade endpoint.""" + + async with async_timeout.timeout(10): + shade_entries = await self.shades.get_resources() + + if isinstance(shade_entries, bool): + # hub returns boolean on a 204/423 empty response (maintenance) + # continual polling results in inevitable error + raise UpdateFailed("Powerview Hub is undergoing maintenance") + + if not shade_entries: + raise UpdateFailed("Failed to fetch new shade data") + + # only update if shade_entries is valid + self.data.store_group_data(shade_entries[SHADE_DATA]) + + return self.data diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 493b5d53639..3f7d04f5b87 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -1,14 +1,25 @@ """Support for hunter douglas shades.""" -from abc import abstractmethod -import asyncio -from contextlib import suppress -import logging +from __future__ import annotations -from aiopvapi.helpers.constants import ATTR_POSITION1, ATTR_POSITION_DATA +import asyncio +from collections.abc import Iterable +from contextlib import suppress +from datetime import timedelta +import logging +from typing import Any + +from aiopvapi.helpers.constants import ( + ATTR_POSITION1, + ATTR_POSITION2, + ATTR_POSITION_DATA, +) from aiopvapi.resources.shade import ( ATTR_POSKIND1, + ATTR_POSKIND2, MAX_POSITION, MIN_POSITION, + BaseShade, + ShadeTdbu, Silhouette, factory as PvShade, ) @@ -22,25 +33,24 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import ( - COORDINATOR, - DEVICE_INFO, - DEVICE_MODEL, DOMAIN, LEGACY_DEVICE_MODEL, - PV_API, - PV_ROOM_DATA, - PV_SHADE_DATA, + POS_KIND_PRIMARY, + POS_KIND_SECONDARY, + POS_KIND_VANE, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE, - SHADE_RESPONSE, STATE_ATTRIBUTE_ROOM_NAME, ) +from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity +from .model import PowerviewDeviceInfo, PowerviewEntryData +from .shade_data import PowerviewShadeMove _LOGGER = logging.getLogger(__name__) @@ -52,11 +62,15 @@ PARALLEL_UPDATES = 1 RESYNC_DELAY = 60 -POSKIND_NONE = 0 -POSKIND_PRIMARY = 1 -POSKIND_SECONDARY = 2 -POSKIND_VANE = 3 -POSKIND_ERROR = 4 +# this equates to 0.75/100 in terms of hass blind position +# some blinds in a closed position report less than 655.35 (1%) +# but larger than 0 even though they are clearly closed +# Find 1 percent of MAX_POSITION, then find 75% of that number +# The means currently 491.5125 or less is closed position +# implemented for top/down shades, but also works fine with normal shades +CLOSED_POSITION = (0.75 / 100) * (MAX_POSITION - MIN_POSITION) + +SCAN_INTERVAL = timedelta(minutes=10) async def async_setup_entry( @@ -64,19 +78,14 @@ async def async_setup_entry( ) -> None: """Set up the hunter douglas shades.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - room_data = pv_data[PV_ROOM_DATA] - shade_data = pv_data[PV_SHADE_DATA] - pv_request = pv_data[PV_API] - coordinator = pv_data[COORDINATOR] - device_info = pv_data[DEVICE_INFO] + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] + coordinator: PowerviewShadeUpdateCoordinator = pv_entry.coordinator - entities = [] - for raw_shade in shade_data.values(): + entities: list[ShadeEntity] = [] + for raw_shade in pv_entry.shade_data.values(): # The shade may be out of sync with the hub - # so we force a refresh when we add it if - # possible - shade = PvShade(raw_shade, pv_request) + # so we force a refresh when we add it if possible + shade: BaseShade = PvShade(raw_shade, pv_entry.api) name_before_refresh = shade.name with suppress(asyncio.TimeoutError): async with async_timeout.timeout(1): @@ -88,37 +97,48 @@ async def async_setup_entry( name_before_refresh, ) continue + coordinator.data.update_shade_positions(shade.raw_data) room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - entities.append( + room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + entities.extend( create_powerview_shade_entity( - coordinator, device_info, room_name, shade, name_before_refresh + coordinator, pv_entry.device_info, room_name, shade, name_before_refresh ) ) async_add_entities(entities) def create_powerview_shade_entity( - coordinator, device_info, room_name, shade, name_before_refresh -): + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name_before_refresh: str, +) -> Iterable[ShadeEntity]: """Create a PowerViewShade entity.""" - - if isinstance(shade, Silhouette): - return PowerViewShadeSilhouette( - coordinator, device_info, room_name, shade, name_before_refresh - ) - - return PowerViewShade( - coordinator, device_info, room_name, shade, name_before_refresh - ) + classes: list[BaseShade] = [] + # order here is important as both ShadeTDBU are listed in aiovapi as can_tilt + # and both require their own class here to work + if isinstance(shade, ShadeTdbu): + classes.extend([PowerViewShadeTDBUTop, PowerViewShadeTDBUBottom]) + elif isinstance(shade, Silhouette): + classes.append(PowerViewShadeSilhouette) + elif shade.can_tilt: + classes.append(PowerViewShadeWithTilt) + else: + classes.append(PowerViewShade) + return [ + cls(coordinator, device_info, room_name, shade, name_before_refresh) + for cls in classes + ] -def hd_position_to_hass(hd_position, max_val): +def hd_position_to_hass(hd_position: int, max_val: int = MAX_POSITION) -> int: """Convert hunter douglas position to hass position.""" return round((hd_position / max_val) * 100) -def hass_position_to_hd(hass_position, max_val): +def hass_position_to_hd(hass_position: int, max_val: int = MAX_POSITION) -> int: """Convert hass position to hunter douglas position.""" return int(hass_position / 100 * max_val) @@ -126,132 +146,146 @@ def hass_position_to_hd(hass_position, max_val): class PowerViewShadeBase(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" - # The hub frequently reports stale states - _attr_assumed_state = True + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = 0 - def __init__(self, coordinator, device_info, room_name, shade, name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) - self._shade = shade - self._is_opening = False - self._is_closing = False - self._last_action_timestamp = 0 - self._scheduled_transition_update = None - self._current_hd_cover_position = MIN_POSITION - if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: + self._shade: BaseShade = shade + self._attr_name = self._shade_name + self._scheduled_transition_update: CALLBACK_TYPE | None = None + if self._device_info.model != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync = None @property - def extra_state_attributes(self): + def assumed_state(self) -> bool: + """If the device is hard wired we are polling state. + + The hub will frequently provide the wrong state + for battery power devices so we set assumed + state in this case. + """ + return not self._is_hard_wired + + @property + def should_poll(self) -> bool: + """Only poll if the device is hard wired. + + We cannot poll battery powered devices + as it would drain their batteries in a matter + of days. + """ + return self._is_hard_wired + + @property + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" - return self._current_hd_cover_position == MIN_POSITION + return self.positions.primary <= CLOSED_POSITION @property - def is_opening(self): - """Return if the cover is opening.""" - return self._is_opening - - @property - def is_closing(self): - """Return if the cover is closing.""" - return self._is_closing - - @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self._current_hd_cover_position, MAX_POSITION) + return hd_position_to_hass(self.positions.primary, MAX_POSITION) @property - def device_class(self): - """Return device class.""" - return CoverDeviceClass.SHADE + def transition_steps(self) -> int: + """Return the steps to make a move.""" + return hd_position_to_hass(self.positions.primary, MAX_POSITION) @property - def name(self): - """Return the name of the shade.""" - return self._shade_name + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove(self._shade.open_position, {}) - async def async_close_cover(self, **kwargs): + @property + def close_position(self) -> PowerviewShadeMove: + """Return the close position and required additional positions.""" + return PowerviewShadeMove(self._shade.close_position, {}) + + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._async_move(0) + self._async_schedule_update_for_transition(self.transition_steps) + await self._async_execute_move(self.close_position) + self._attr_is_opening = False + self._attr_is_closing = True + self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._async_move(100) + self._async_schedule_update_for_transition(100 - self.transition_steps) + await self._async_execute_move(self.open_position) + self._attr_is_opening = True + self._attr_is_closing = False + self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - # Cancel any previous updates self._async_cancel_scheduled_transition_update() - self._async_update_from_command(await self._shade.stop()) + self.data.update_from_response(await self._shade.stop()) await self._async_force_refresh_state() - async def async_set_cover_position(self, **kwargs): - """Move the shade to a specific position.""" - if ATTR_POSITION not in kwargs: - return - await self._async_move(kwargs[ATTR_POSITION]) + @callback + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + # no override required in base + return target_hass_position - async def _async_move(self, target_hass_position): + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(kwargs[ATTR_POSITION]) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_one = hass_position_to_hd(target_hass_position) + return PowerviewShadeMove( + {ATTR_POSITION1: position_one, ATTR_POSKIND1: POS_KIND_PRIMARY}, {} + ) + + async def _async_execute_move(self, move: PowerviewShadeMove) -> None: + """Execute a move that can affect multiple positions.""" + response = await self._shade.move(move.request) + # Process any positions we know will update as result + # of the request since the hub won't return them + for kind, position in move.new_positions.items(): + self.data.update_shade_position(self._shade.id, position, kind) + # Finally process the response + self.data.update_from_response(response) + + async def _async_set_cover_position(self, target_hass_position: int) -> None: """Move the shade to a position.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION + target_hass_position = self._clamp_cover_limit(target_hass_position) + current_hass_position = self.current_cover_position + self._async_schedule_update_for_transition( + abs(current_hass_position - target_hass_position) ) - steps_to_move = abs(current_hass_position - target_hass_position) - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command( - await self._shade.move( - { - ATTR_POSITION1: hass_position_to_hd( - target_hass_position, MAX_POSITION - ), - ATTR_POSKIND1: POSKIND_PRIMARY, - } - ) - ) - self._is_opening = False - self._is_closing = False - if target_hass_position > current_hass_position: - self._is_opening = True - elif target_hass_position < current_hass_position: - self._is_closing = True + await self._async_execute_move(self._get_shade_move(target_hass_position)) + self._attr_is_opening = target_hass_position > current_hass_position + self._attr_is_closing = target_hass_position < current_hass_position self.async_write_ha_state() @callback - def _async_update_from_command(self, raw_data): - """Update the shade state after a command.""" - if not raw_data or SHADE_RESPONSE not in raw_data: - return - self._async_process_new_shade_data(raw_data[SHADE_RESPONSE]) - - @callback - def _async_process_new_shade_data(self, data): - """Process new data from an update.""" - self._shade.raw_data = data - self._async_update_current_cover_position() - - @callback - def _async_update_current_cover_position(self): + def _async_update_shade_data(self, shade_data: dict[str | int, Any]) -> None: """Update the current cover position from the data.""" - _LOGGER.debug("Raw data update: %s", self._shade.raw_data) - position_data = self._shade.raw_data.get(ATTR_POSITION_DATA, {}) - self._async_process_updated_position_data(position_data) - self._is_opening = False - self._is_closing = False + self.data.update_shade_positions(shade_data) + self._attr_is_opening = False + self._attr_is_closing = False @callback - @abstractmethod - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - - @callback - def _async_cancel_scheduled_transition_update(self): + def _async_cancel_scheduled_transition_update(self) -> None: """Cancel any previous updates.""" if self._scheduled_transition_update: self._scheduled_transition_update() @@ -261,9 +295,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._forced_resync = None @callback - def _async_schedule_update_for_transition(self, steps): - self.async_write_ha_state() - + def _async_schedule_update_for_transition(self, steps: int) -> None: # Cancel any previous updates self._async_cancel_scheduled_transition_update() @@ -278,7 +310,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): est_time_to_complete_transition, ) - # Schedule an update for when we expect the transition + # Schedule an forced update for when we expect the transition # to be completed. self._scheduled_transition_update = async_call_later( self.hass, @@ -295,139 +327,303 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self.hass, RESYNC_DELAY, self._async_force_resync ) - async def _async_force_resync(self, *_): + async def _async_force_resync(self, *_: Any) -> None: """Force a resync after an update since the hub may have stale state.""" self._forced_resync = None + _LOGGER.debug("Force resync of shade %s", self.name) await self._async_force_refresh_state() - async def _async_force_refresh_state(self): + async def _async_force_refresh_state(self) -> None: """Refresh the cover state and force the device cache to be bypassed.""" - await self._shade.refresh() - self._async_update_current_cover_position() + await self.async_update() self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" - self._async_update_current_cover_position() self.async_on_remove( self.coordinator.async_add_listener(self._async_update_shade_from_group) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Cancel any pending refreshes.""" self._async_cancel_scheduled_transition_update() + @property + def _update_in_progress(self) -> bool: + """Check if an update is already in progress.""" + return bool(self._scheduled_transition_update or self._forced_resync) + @callback - def _async_update_shade_from_group(self): + def _async_update_shade_from_group(self) -> None: """Update with new data from the coordinator.""" - if self._scheduled_transition_update or self._forced_resync: - # If a transition in in progress - # the data will be wrong + if self._update_in_progress: + # If a transition is in progress the data will be wrong return - self._async_process_new_shade_data(self.coordinator.data[self._shade.id]) + self.data.update_from_group_data(self._shade.id) self.async_write_ha_state() + async def async_update(self) -> None: + """Refresh shade position.""" + if self._update_in_progress: + # The update will likely timeout and + # error if are already have one in flight + return + await self._shade.refresh() + self._async_update_shade_data(self._shade.raw_data) + class PowerViewShade(PowerViewShadeBase): """Represent a standard shade.""" - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_supported_features |= ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + + +class PowerViewShadeTDBU(PowerViewShade): + """Representation of a PowerView shade with top/down bottom/up capabilities.""" + + @property + def transition_steps(self) -> int: + """Return the steps to make a move.""" + return hd_position_to_hass( + self.positions.primary, MAX_POSITION + ) + hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + +class PowerViewShadeTDBUBottom(PowerViewShadeTDBU): + """Representation of a top down bottom up powerview shade.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._shade.id}_bottom" + self._attr_name = f"{self._shade_name} Bottom" @callback - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - if ATTR_POSITION1 in position_data: - self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return min(target_hass_position, (100 - cover_top)) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_bottom = hass_position_to_hd(target_hass_position) + position_top = self.positions.secondary + return PowerviewShadeMove( + { + ATTR_POSITION1: position_bottom, + ATTR_POSITION2: position_top, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + }, + {}, + ) + + +class PowerViewShadeTDBUTop(PowerViewShadeTDBU): + """Representation of a top down bottom up powerview shade.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._shade.id}_top" + self._attr_name = f"{self._shade_name} Top" + # these shades share a class in parent API + # override open position for top shade + self._shade.open_position = { + ATTR_POSITION1: MIN_POSITION, + ATTR_POSITION2: MAX_POSITION, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + } + + @property + def should_poll(self) -> bool: + """Certain shades create multiple entities. + + Do not poll shade multiple times. One shade will return data + for both and multiple polling will cause timeouts. + """ + return False + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + # top shade needs to check other motor + return self.positions.secondary <= CLOSED_POSITION + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + # these need to be inverted to report state correctly in HA + return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + @callback + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) + return min(target_hass_position, (100 - cover_bottom)) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_bottom = self.positions.primary + position_top = hass_position_to_hd(target_hass_position, MAX_POSITION) + return PowerviewShadeMove( + { + ATTR_POSITION1: position_bottom, + ATTR_POSITION2: position_top, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + }, + {}, + ) class PowerViewShadeWithTilt(PowerViewShade): """Representation of a PowerView shade with tilt capabilities.""" - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.STOP_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) - _max_tilt = MAX_POSITION - _tilt_steps = 10 - def __init__(self, coordinator, device_info, room_name, shade, name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) - self._attr_current_cover_tilt_position = 0 - - async def async_open_cover_tilt(self, **kwargs): - """Open the cover tilt.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION ) - steps_to_move = current_hass_position + self._tilt_steps - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command(await self._shade.tilt_open()) + if self._device_info.model != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT - async def async_close_cover_tilt(self, **kwargs): + @property + def current_cover_tilt_position(self) -> int: + """Return the current cover tile position.""" + return hd_position_to_hass(self.positions.vane, self._max_tilt) + + @property + def transition_steps(self): + """Return the steps to make a move.""" + return hd_position_to_hass( + self.positions.primary, MAX_POSITION + ) + hd_position_to_hass(self.positions.vane, self._max_tilt) + + @property + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + self._shade.open_position, {POS_KIND_VANE: MIN_POSITION} + ) + + @property + def close_position(self) -> PowerviewShadeMove: + """Return the close position and required additional positions.""" + return PowerviewShadeMove( + self._shade.close_position, {POS_KIND_VANE: MIN_POSITION} + ) + + @property + def open_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + # next upstream api release to include self._shade.open_tilt_position + return PowerviewShadeMove( + {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: self._max_tilt}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + @property + def close_tilt_position(self) -> PowerviewShadeMove: + """Return the close tilt position and required additional positions.""" + # next upstream api release to include self._shade.close_tilt_position + return PowerviewShadeMove( + {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: MIN_POSITION}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION - ) - steps_to_move = current_hass_position + self._tilt_steps - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command(await self._shade.tilt_close()) + self._async_schedule_update_for_transition(self.transition_steps) + await self._async_execute_move(self.close_tilt_position) + self.async_write_ha_state() - async def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - target_hass_tilt_position = kwargs[ATTR_TILT_POSITION] - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION - ) - steps_to_move = current_hass_position + self._tilt_steps + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + self._async_schedule_update_for_transition(100 - self.transition_steps) + await self._async_execute_move(self.open_tilt_position) + self.async_write_ha_state() - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command( - await self._shade.move( - { - ATTR_POSITION1: hass_position_to_hd( - target_hass_tilt_position, self._max_tilt - ), - ATTR_POSKIND1: POSKIND_VANE, - } - ) - ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the vane to a specific position.""" + await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION]) - async def async_stop_cover_tilt(self, **kwargs): - """Stop the cover tilting.""" - # Cancel any previous updates - await self.async_stop_cover() + async def _async_set_cover_tilt_position( + self, target_hass_tilt_position: int + ) -> None: + """Move the vane to a specific position.""" + final_position = self.current_cover_position + target_hass_tilt_position + self._async_schedule_update_for_transition( + abs(self.transition_steps - final_position) + ) + await self._async_execute_move(self._get_shade_tilt(target_hass_tilt_position)) + self.async_write_ha_state() @callback - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - if ATTR_POSKIND1 not in position_data: - return - if int(position_data[ATTR_POSKIND1]) == POSKIND_PRIMARY: - self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) - self._attr_current_cover_tilt_position = 0 - if int(position_data[ATTR_POSKIND1]) == POSKIND_VANE: - self._current_hd_cover_position = MIN_POSITION - self._attr_current_cover_tilt_position = hd_position_to_hass( - int(position_data[ATTR_POSITION1]), self._max_tilt - ) + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + """Return a PowerviewShadeMove.""" + position_shade = hass_position_to_hd(target_hass_position) + return PowerviewShadeMove( + {ATTR_POSITION1: position_shade, ATTR_POSKIND1: POS_KIND_PRIMARY}, + {POS_KIND_VANE: MIN_POSITION}, + ) + + @callback + def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: + """Return a PowerviewShadeMove.""" + position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) + return PowerviewShadeMove( + {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilting.""" + await self.async_stop_cover() class PowerViewShadeSilhouette(PowerViewShadeWithTilt): """Representation of a Silhouette PowerView shade.""" - def __init__(self, coordinator, device_info, room_name, shade, name): - """Initialize the shade.""" - super().__init__(coordinator, device_info, room_name, shade, name) - self._max_tilt = 32767 - self._tilt_steps = 4 + _max_tilt = 32767 diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py new file mode 100644 index 00000000000..12f424ea501 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -0,0 +1,104 @@ +"""Diagnostics support for Powerview Hunter Douglas.""" +from __future__ import annotations + +from dataclasses import asdict +import logging +from typing import Any + +import attr + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN, REDACT_HUB_ADDRESS, REDACT_MAC_ADDRESS, REDACT_SERIAL_NUMBER +from .model import PowerviewEntryData + +REDACT_CONFIG = { + CONF_HOST, + REDACT_HUB_ADDRESS, + REDACT_MAC_ADDRESS, + REDACT_SERIAL_NUMBER, + ATTR_CONFIGURATION_URL, +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = _async_get_diagnostics(hass, entry) + device_registry = dr.async_get(hass) + data.update( + device_info=[ + _async_device_as_dict(hass, device) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + ], + ) + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + data = _async_get_diagnostics(hass, entry) + data["device_info"] = _async_device_as_dict(hass, device) + # try to match on name to restrict to shade if we can + # otherwise just return all shade data + # shade name is unique in powerview + shade_data = data["shade_data"] + for shade in shade_data: + if shade_data[shade]["name_unicode"] == device.name: + data["shade_data"] = shade_data[shade] + return data + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] + shade_data = pv_entry.coordinator.data.get_all_raw_data() + hub_info = async_redact_data(asdict(pv_entry.device_info), REDACT_CONFIG) + return {"hub_info": hub_info, "shade_data": shade_data} + + +@callback +def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, Any]: + """Represent a Powerview device as a dictionary.""" + + # Gather information how this device is represented in Home Assistant + entity_registry = er.async_get(hass) + + data = async_redact_data(attr.asdict(device), REDACT_CONFIG) + data["entities"] = [] + entities: list[dict[str, Any]] = data["entities"] + + entries = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + for entity_entry in entries: + state = hass.states.get(entity_entry.entity_id) + state_dict = None + if state: + state_dict = dict(state.as_dict()) + state_dict.pop("context", None) + + entity = attr.asdict(entity_entry) + entity["state"] = state_dict + entities.append(entity) + + return data diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 50894d59f8b..a2bbf39fb96 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -1,6 +1,6 @@ -"""The nexia integration base entity.""" +"""The powerview integration base entity.""" -from aiopvapi.resources.shade import ATTR_TYPE +from aiopvapi.resources.shade import ATTR_TYPE, BaseShade from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION import homeassistant.helpers.device_registry as dr @@ -8,11 +8,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DEVICE_FIRMWARE, - DEVICE_MAC_ADDRESS, - DEVICE_MODEL, - DEVICE_NAME, - DEVICE_SERIAL_NUMBER, + ATTR_BATTERY_KIND, + BATTERY_KIND_HARDWIRED, DOMAIN, FIRMWARE, FIRMWARE_BUILD, @@ -20,49 +17,72 @@ from .const import ( FIRMWARE_SUB_REVISION, MANUFACTURER, ) +from .coordinator import PowerviewShadeUpdateCoordinator +from .model import PowerviewDeviceInfo +from .shade_data import PowerviewShadeData, PowerviewShadePositions -class HDEntity(CoordinatorEntity): +class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): """Base class for hunter douglas entities.""" - def __init__(self, coordinator, device_info, room_name, unique_id): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + unique_id: str, + ) -> None: """Initialize the entity.""" super().__init__(coordinator) self._room_name = room_name - self._unique_id = unique_id + self._attr_unique_id = unique_id self._device_info = device_info @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id + def data(self) -> PowerviewShadeData: + """Return the PowerviewShadeData.""" + return self.coordinator.data @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - firmware = self._device_info[DEVICE_FIRMWARE] + firmware = self._device_info.firmware sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" return DeviceInfo( - connections={ - (dr.CONNECTION_NETWORK_MAC, self._device_info[DEVICE_MAC_ADDRESS]) - }, - identifiers={(DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER])}, + connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)}, + identifiers={(DOMAIN, self._device_info.serial_number)}, manufacturer=MANUFACTURER, - model=self._device_info[DEVICE_MODEL], - name=self._device_info[DEVICE_NAME], + model=self._device_info.model, + name=self._device_info.name, suggested_area=self._room_name, sw_version=sw_version, + configuration_url=f"http://{self._device_info.hub_address}/api/shades", ) class ShadeEntity(HDEntity): """Base class for hunter douglas shade entities.""" - def __init__(self, coordinator, device_info, room_name, shade, shade_name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + shade_name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade + self._is_hard_wired = bool( + shade.raw_data.get(ATTR_BATTERY_KIND) == BATTERY_KIND_HARDWIRED + ) + + @property + def positions(self) -> PowerviewShadePositions: + """Return the PowerviewShadeData.""" + return self.data.get_shade_positions(self._shade.id) @property def device_info(self) -> DeviceInfo: @@ -74,11 +94,12 @@ class ShadeEntity(HDEntity): suggested_area=self._room_name, manufacturer=MANUFACTURER, model=str(self._shade.raw_data[ATTR_TYPE]), - via_device=(DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]), + via_device=(DOMAIN, self._device_info.serial_number), + configuration_url=f"http://{self._device_info.hub_address}/api/shades/{self._shade.id}", ) for shade in self._shade.shade_types: - if shade.shade_type == device_info[ATTR_MODEL]: + if str(shade.shade_type) == device_info[ATTR_MODEL]: device_info[ATTR_MODEL] = shade.description break diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index af6aea17de3..8e2206b2778 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -3,7 +3,7 @@ "name": "Hunter Douglas PowerView", "documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview", "requirements": ["aiopvapi==1.6.19"], - "codeowners": ["@bdraco", "@trullock"], + "codeowners": ["@bdraco", "@kingy444", "@trullock"], "config_flow": true, "homekit": { "models": ["PowerView"] @@ -17,5 +17,8 @@ ], "zeroconf": ["_powerview._tcp.local."], "iot_class": "local_polling", - "loggers": ["aiopvapi"] + "loggers": ["aiopvapi"], + "supported_brands": { + "luxaflex": "Luxaflex" + } } diff --git a/homeassistant/components/hunterdouglas_powerview/model.py b/homeassistant/components/hunterdouglas_powerview/model.py new file mode 100644 index 00000000000..b7ad4a7439c --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/model.py @@ -0,0 +1,33 @@ +"""Define Hunter Douglas data models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from aiopvapi.helpers.aiorequest import AioRequest + +from .coordinator import PowerviewShadeUpdateCoordinator + + +@dataclass +class PowerviewEntryData: + """Define class for main domain information.""" + + api: AioRequest + room_data: dict[str, Any] + scene_data: dict[str, Any] + shade_data: dict[str, Any] + coordinator: PowerviewShadeUpdateCoordinator + device_info: PowerviewDeviceInfo + + +@dataclass +class PowerviewDeviceInfo: + """Define class for device information.""" + + name: str + mac_address: str + serial_number: str + firmware: dict[str, Any] + model: str + hub_address: str diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 3476db4949c..ba1221a25ac 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -10,17 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - COORDINATOR, - DEVICE_INFO, - DOMAIN, - PV_API, - PV_ROOM_DATA, - PV_SCENE_DATA, - ROOM_NAME_UNICODE, - STATE_ATTRIBUTE_ROOM_NAME, -) +from .const import DOMAIN, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME from .entity import HDEntity +from .model import PowerviewEntryData async def async_setup_entry( @@ -28,18 +20,15 @@ async def async_setup_entry( ) -> None: """Set up powerview scene entries.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - room_data = pv_data[PV_ROOM_DATA] - scene_data = pv_data[PV_SCENE_DATA] - pv_request = pv_data[PV_API] - coordinator = pv_data[COORDINATOR] - device_info = pv_data[DEVICE_INFO] + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] pvscenes = [] - for raw_scene in scene_data.values(): - scene = PvScene(raw_scene, pv_request) - room_name = room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") - pvscenes.append(PowerViewScene(coordinator, device_info, room_name, scene)) + for raw_scene in pv_entry.scene_data.values(): + scene = PvScene(raw_scene, pv_entry.api) + room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") + pvscenes.append( + PowerViewScene(pv_entry.coordinator, pv_entry.device_info, room_name, scene) + ) async_add_entities(pvscenes) diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 43e438041f2..6328ad63bc2 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -1,5 +1,5 @@ """Support for hunterdouglass_powerview sensors.""" -from aiopvapi.resources.shade import factory as PvShade +from aiopvapi.resources.shade import BaseShade, factory as PvShade from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,18 +13,14 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - COORDINATOR, - DEVICE_INFO, DOMAIN, - PV_API, - PV_ROOM_DATA, - PV_SHADE_DATA, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE, SHADE_BATTERY_LEVEL, SHADE_BATTERY_LEVEL_MAX, ) from .entity import ShadeEntity +from .model import PowerviewEntryData async def async_setup_entry( @@ -32,24 +28,23 @@ async def async_setup_entry( ) -> None: """Set up the hunter douglas shades sensors.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - room_data = pv_data[PV_ROOM_DATA] - shade_data = pv_data[PV_SHADE_DATA] - pv_request = pv_data[PV_API] - coordinator = pv_data[COORDINATOR] - device_info = pv_data[DEVICE_INFO] + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities = [] - for raw_shade in shade_data.values(): - shade = PvShade(raw_shade, pv_request) + for raw_shade in pv_entry.shade_data.values(): + shade: BaseShade = PvShade(raw_shade, pv_entry.api) if SHADE_BATTERY_LEVEL not in shade.raw_data: continue name_before_refresh = shade.name room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") entities.append( PowerViewShadeBatterySensor( - coordinator, device_info, room_name, shade, name_before_refresh + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + name_before_refresh, ) ) async_add_entities(entities) @@ -63,16 +58,16 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, coordinator, device_info, room_name, shade, name): + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._attr_unique_id}_charge" + @property def name(self): """Name of the shade battery.""" return f"{self._shade_name} Battery" - @property - def unique_id(self): - """Shade battery Uniqueid.""" - return f"{self._unique_id}_charge" - @property def native_value(self): """Get the current value in percentage.""" @@ -89,5 +84,9 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): @callback def _async_update_shade_from_group(self): """Update with new data from the coordinator.""" - self._shade.raw_data = self.coordinator.data[self._shade.id] + self._shade.raw_data = self.data.get_raw_data(self._shade.id) self.async_write_ha_state() + + async def async_update(self) -> None: + """Refresh shade battery.""" + await self._shade.refreshBattery() diff --git a/homeassistant/components/hunterdouglas_powerview/shade_data.py b/homeassistant/components/hunterdouglas_powerview/shade_data.py new file mode 100644 index 00000000000..b66024aec7f --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -0,0 +1,117 @@ +"""Shade data for the Hunter Douglas PowerView integration.""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +import logging +from typing import Any + +from aiopvapi.helpers.constants import ( + ATTR_ID, + ATTR_POSITION1, + ATTR_POSITION2, + ATTR_POSITION_DATA, + ATTR_POSKIND1, + ATTR_POSKIND2, + ATTR_SHADE, +) +from aiopvapi.resources.shade import MIN_POSITION + +from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE +from .util import async_map_data_by_id + +POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2)) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class PowerviewShadeMove: + """Request to move a powerview shade.""" + + # The positions to request on the hub + request: dict[str, int] + + # The positions that will also change + # as a result of the request that the + # hub will not send back + new_positions: dict[int, int] + + +@dataclass +class PowerviewShadePositions: + """Positions for a powerview shade.""" + + primary: int = MIN_POSITION + secondary: int = MIN_POSITION + vane: int = MIN_POSITION + + +class PowerviewShadeData: + """Coordinate shade data between multiple api calls.""" + + def __init__(self): + """Init the shade data.""" + self._group_data_by_id: dict[int, dict[str | int, Any]] = {} + self.positions: dict[int, PowerviewShadePositions] = {} + + def get_raw_data(self, shade_id: int) -> dict[str | int, Any]: + """Get data for the shade.""" + return self._group_data_by_id[shade_id] + + def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]: + """Get data for all shades.""" + return self._group_data_by_id + + def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions: + """Get positions for a shade.""" + if shade_id not in self.positions: + self.positions[shade_id] = PowerviewShadePositions() + return self.positions[shade_id] + + def update_from_group_data(self, shade_id: int) -> None: + """Process an update from the group data.""" + self.update_shade_positions(self._group_data_by_id[shade_id]) + + def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None: + """Store data from the all shades endpoint. + + This does not update the shades or positions + as the data may be stale. update_from_group_data + with a shade_id will update a specific shade + from the group data. + """ + self._group_data_by_id = async_map_data_by_id(shade_data) + + def update_shade_position(self, shade_id: int, position: int, kind: int) -> None: + """Update a single shade position.""" + positions = self.get_shade_positions(shade_id) + if kind == POS_KIND_PRIMARY: + positions.primary = position + elif kind == POS_KIND_SECONDARY: + positions.secondary = position + elif kind == POS_KIND_VANE: + positions.vane = position + + def update_from_position_data( + self, shade_id: int, position_data: dict[str, Any] + ) -> None: + """Update the shade positions from the position data.""" + for position_key, kind_key in POSITIONS: + if position_key in position_data: + self.update_shade_position( + shade_id, position_data[position_key], position_data[kind_key] + ) + + def update_shade_positions(self, data: dict[int | str, Any]) -> None: + """Update a shades from data dict.""" + _LOGGER.debug("Raw data update: %s", data) + shade_id = data[ATTR_ID] + position_data = data[ATTR_POSITION_DATA] + self.update_from_position_data(shade_id, position_data) + + def update_from_response(self, response: dict[str, Any]) -> None: + """Update from the response to a command.""" + if response and ATTR_SHADE in response: + shade_data: dict[int | str, Any] = response[ATTR_SHADE] + self.update_shade_positions(shade_data) diff --git a/homeassistant/components/hunterdouglas_powerview/translations/fr.json b/homeassistant/components/hunterdouglas_powerview/translations/fr.json index 9eb8edda7db..c6ad82dc7ab 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/fr.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Voulez-vous configurer {name} ({host})?", + "description": "Voulez-vous configurer {name} ({host})\u00a0?", "title": "Connectez-vous au concentrateur PowerView" }, "user": { diff --git a/homeassistant/components/hunterdouglas_powerview/translations/sv.json b/homeassistant/components/hunterdouglas_powerview/translations/sv.json index 04371b16514..e572ec2c4a7 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/sv.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/sv.json @@ -9,7 +9,7 @@ }, "step": { "link": { - "description": "Do vill du konfigurera {name} ({host})?", + "description": "Vill du konfigurera {name} ({host})?", "title": "Anslut till PowerView Hub" }, "user": { diff --git a/homeassistant/components/hunterdouglas_powerview/util.py b/homeassistant/components/hunterdouglas_powerview/util.py new file mode 100644 index 00000000000..15330f30bdb --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/util.py @@ -0,0 +1,15 @@ +"""Coordinate data for powerview devices.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from aiopvapi.helpers.constants import ATTR_ID + +from homeassistant.core import callback + + +@callback +def async_map_data_by_id(data: Iterable[dict[str | int, Any]]): + """Return a dict with the key being the id for a list of entries.""" + return {entry[ATTR_ID]: entry for entry in data} diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 488579af12b..d96ab359dda 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -1,5 +1,8 @@ """Config flow for HVV integration.""" +from __future__ import annotations + import logging +from typing import Any from pygti.auth import GTI_DEFAULT_HOST from pygti.exceptions import CannotConnect, InvalidAuth @@ -122,7 +125,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get options flow.""" return OptionsFlowHandler(config_entry) @@ -130,12 +135,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Options flow handler.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize HVV Departures options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) - self.departure_filters = {} - self.hub = None + self.departure_filters: dict[str, Any] = {} async def async_step_init(self, user_input=None): """Manage the options.""" @@ -143,10 +147,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if not self.departure_filters: departure_list = {} - self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] + hub: GTIHub = self.hass.data[DOMAIN][self.config_entry.entry_id] try: - departure_list = await self.hub.gti.departureList( + departure_list = await hub.gti.departureList( { "station": self.config_entry.data[CONF_STATION], "time": {"date": "heute", "time": "jetzt"}, diff --git a/homeassistant/components/hvv_departures/translations/ca.json b/homeassistant/components/hvv_departures/translations/ca.json index fad60206c1c..05da353f879 100644 --- a/homeassistant/components/hvv_departures/translations/ca.json +++ b/homeassistant/components/hvv_departures/translations/ca.json @@ -36,7 +36,7 @@ "init": { "data": { "filter": "Selecciona l\u00ednies", - "offset": "\u00d2fset (minuts)", + "offset": "Desfasament (minuts)", "real_time": "Utilitza dades en temps real" }, "description": "Canvia les opcions d'aquest sensor de sortides", diff --git a/homeassistant/components/hvv_departures/translations/sv.json b/homeassistant/components/hvv_departures/translations/sv.json index ff31d1c1484..3a2983d1035 100644 --- a/homeassistant/components/hvv_departures/translations/sv.json +++ b/homeassistant/components/hvv_departures/translations/sv.json @@ -1,4 +1,16 @@ { + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 4d6253cb161..97e97cd835d 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from contextlib import suppress import logging from typing import Any @@ -140,12 +141,9 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_auth() return await self.async_step_confirm() - async def async_step_reauth( - self, - config_data: dict[str, Any], - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthentication flow.""" - self._data = dict(config_data) + self._data = dict(entry_data) async with self._create_client(raw_connection=True) as hyperion_client: if not hyperion_client: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index db9aa000066..254bf6f685f 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -1,4 +1,6 @@ """iAlarm integration.""" +from __future__ import annotations + import asyncio import logging @@ -20,8 +22,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up iAlarm config.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] + host: str = entry.data[CONF_HOST] + port: int = entry.data[CONF_PORT] ialarm = IAlarm(host, port) try: @@ -55,11 +57,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class IAlarmDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching iAlarm data.""" - def __init__(self, hass, ialarm, mac): + def __init__(self, hass: HomeAssistant, ialarm: IAlarm, mac: str) -> None: """Initialize global iAlarm data updater.""" self.ialarm = ialarm - self.state = None - self.host = ialarm.host + self.state: str | None = None + self.host: str = ialarm.host self.mac = mac super().__init__( diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index be53eb99525..6a4e3d191eb 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -1,4 +1,6 @@ """Interfaces with iAlarm control panels.""" +from __future__ import annotations + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -9,6 +11,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import IAlarmDataUpdateCoordinator from .const import DATA_COORDINATOR, DOMAIN @@ -16,50 +19,46 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a iAlarm alarm control panel based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: IAlarmDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] async_add_entities([IAlarmPanel(coordinator)], False) -class IAlarmPanel(CoordinatorEntity, AlarmControlPanelEntity): +class IAlarmPanel( + CoordinatorEntity[IAlarmDataUpdateCoordinator], AlarmControlPanelEntity +): """Representation of an iAlarm device.""" + _attr_name = "iAlarm" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, + def __init__(self, coordinator: IAlarmDataUpdateCoordinator) -> None: + """Create the entity with a DataUpdateCoordinator.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.mac)}, manufacturer="Antifurto365 - Meian", - name=self.name, + name="iAlarm", ) + self._attr_unique_id = coordinator.mac @property - def unique_id(self): - """Return a unique id.""" - return self.coordinator.mac - - @property - def name(self): - """Return the name.""" - return "iAlarm" - - @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self.coordinator.state - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self.coordinator.ialarm.disarm() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self.coordinator.ialarm.arm_stay() - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self.coordinator.ialarm.arm_away() diff --git a/homeassistant/components/ialarm_xr/__init__.py b/homeassistant/components/ialarm_xr/__init__.py deleted file mode 100644 index 193bbe4fffc..00000000000 --- a/homeassistant/components/ialarm_xr/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -"""iAlarmXR integration.""" -from __future__ import annotations - -import asyncio -import logging - -from async_timeout import timeout -from pyialarmxr import ( - IAlarmXR, - IAlarmXRGenericException, - IAlarmXRSocketTimeoutException, -) - -from homeassistant.components.alarm_control_panel import SCAN_INTERVAL -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DOMAIN -from .utils import async_get_ialarmxr_mac - -PLATFORMS = [Platform.ALARM_CONTROL_PANEL] -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up iAlarmXR config.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - - ialarmxr = IAlarmXR(username, password, host, port) - - try: - async with timeout(10): - ialarmxr_mac = await async_get_ialarmxr_mac(hass, ialarmxr) - except ( - asyncio.TimeoutError, - ConnectionError, - IAlarmXRGenericException, - IAlarmXRSocketTimeoutException, - ) as ex: - raise ConfigEntryNotReady from ex - - coordinator = IAlarmXRDataUpdateCoordinator(hass, ialarmxr, ialarmxr_mac) - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload iAlarmXR config.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok - - -class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching iAlarmXR data.""" - - def __init__(self, hass: HomeAssistant, ialarmxr: IAlarmXR, mac: str) -> None: - """Initialize global iAlarm data updater.""" - self.ialarmxr: IAlarmXR = ialarmxr - self.state: int | None = None - self.host: str = ialarmxr.host - self.mac: str = mac - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - def _update_data(self) -> None: - """Fetch data from iAlarmXR via sync functions.""" - status: int = self.ialarmxr.get_status() - _LOGGER.debug("iAlarmXR status: %s", status) - - self.state = status - - async def _async_update_data(self) -> None: - """Fetch data from iAlarmXR.""" - try: - async with timeout(10): - await self.hass.async_add_executor_job(self._update_data) - except ConnectionError as error: - raise UpdateFailed(error) from error diff --git a/homeassistant/components/ialarm_xr/alarm_control_panel.py b/homeassistant/components/ialarm_xr/alarm_control_panel.py deleted file mode 100644 index b64edb74391..00000000000 --- a/homeassistant/components/ialarm_xr/alarm_control_panel.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Interfaces with iAlarmXR control panels.""" -from __future__ import annotations - -from pyialarmxr import IAlarmXR - -from homeassistant.components.alarm_control_panel import ( - AlarmControlPanelEntity, - AlarmControlPanelEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import IAlarmXRDataUpdateCoordinator -from .const import DOMAIN - -IALARMXR_TO_HASS = { - IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME, - IAlarmXR.DISARMED: STATE_ALARM_DISARMED, - IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED, -} - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up a iAlarmXR alarm control panel based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([IAlarmXRPanel(coordinator)]) - - -class IAlarmXRPanel( - CoordinatorEntity[IAlarmXRDataUpdateCoordinator], AlarmControlPanelEntity -): - """Representation of an iAlarmXR device.""" - - _attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - ) - _attr_name = "iAlarm_XR" - _attr_icon = "mdi:security" - - def __init__(self, coordinator: IAlarmXRDataUpdateCoordinator) -> None: - """Initialize the alarm panel.""" - super().__init__(coordinator) - self._attr_unique_id = coordinator.mac - self._attr_device_info = DeviceInfo( - manufacturer="Antifurto365 - Meian", - name=self.name, - connections={(device_registry.CONNECTION_NETWORK_MAC, coordinator.mac)}, - ) - - @property - def state(self) -> str | None: - """Return the state of the device.""" - return IALARMXR_TO_HASS.get(self.coordinator.state) - - def alarm_disarm(self, code: str | None = None) -> None: - """Send disarm command.""" - self.coordinator.ialarmxr.disarm() - - def alarm_arm_home(self, code: str | None = None) -> None: - """Send arm home command.""" - self.coordinator.ialarmxr.arm_stay() - - def alarm_arm_away(self, code: str | None = None) -> None: - """Send arm away command.""" - self.coordinator.ialarmxr.arm_away() diff --git a/homeassistant/components/ialarm_xr/config_flow.py b/homeassistant/components/ialarm_xr/config_flow.py deleted file mode 100644 index 2a9cc406733..00000000000 --- a/homeassistant/components/ialarm_xr/config_flow.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Config flow for Antifurto365 iAlarmXR integration.""" -from __future__ import annotations - -import logging -from logging import Logger -from typing import Any - -from pyialarmxr import ( - IAlarmXR, - IAlarmXRGenericException, - IAlarmXRSocketTimeoutException, -) -import voluptuous as vol - -from homeassistant import config_entries, core -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN -from .utils import async_get_ialarmxr_mac - -_LOGGER: Logger = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST, default=IAlarmXR.IALARM_P2P_DEFAULT_HOST): str, - vol.Required(CONF_PORT, default=IAlarmXR.IALARM_P2P_DEFAULT_PORT): int, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - - -async def _async_get_device_formatted_mac( - hass: core.HomeAssistant, username: str, password: str, host: str, port: int -) -> str: - """Return iAlarmXR mac address.""" - - ialarmxr = IAlarmXR(username, password, host, port) - return await async_get_ialarmxr_mac(hass, ialarmxr) - - -class IAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Antifurto365 iAlarmXR.""" - - VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - - errors = {} - - if user_input is not None: - mac = None - host = user_input[CONF_HOST] - port = user_input[CONF_PORT] - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - - try: - # If we are able to get the MAC address, we are able to establish - # a connection to the device. - mac = await _async_get_device_formatted_mac( - self.hass, username, password, host, port - ) - except ConnectionError: - errors["base"] = "cannot_connect" - except IAlarmXRGenericException as ialarmxr_exception: - _LOGGER.debug( - "IAlarmXRGenericException with message: [ %s ]", - ialarmxr_exception.message, - ) - errors["base"] = "cannot_connect" - except IAlarmXRSocketTimeoutException as ialarmxr_socket_timeout_exception: - _LOGGER.debug( - "IAlarmXRSocketTimeoutException with message: [ %s ]", - ialarmxr_socket_timeout_exception.message, - ) - errors["base"] = "timeout" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if not errors: - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input - ) - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) diff --git a/homeassistant/components/ialarm_xr/const.py b/homeassistant/components/ialarm_xr/const.py deleted file mode 100644 index 12122277340..00000000000 --- a/homeassistant/components/ialarm_xr/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the iAlarmXR integration.""" - -DOMAIN = "ialarm_xr" diff --git a/homeassistant/components/ialarm_xr/manifest.json b/homeassistant/components/ialarm_xr/manifest.json deleted file mode 100644 index 5befca3b95d..00000000000 --- a/homeassistant/components/ialarm_xr/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "ialarm_xr", - "name": "Antifurto365 iAlarmXR", - "documentation": "https://www.home-assistant.io/integrations/ialarm_xr", - "requirements": ["pyialarmxr-homeassistant==1.0.18"], - "codeowners": ["@bigmoby"], - "config_flow": true, - "iot_class": "cloud_polling", - "loggers": ["pyialarmxr"] -} diff --git a/homeassistant/components/ialarm_xr/utils.py b/homeassistant/components/ialarm_xr/utils.py deleted file mode 100644 index db82a3fcd44..00000000000 --- a/homeassistant/components/ialarm_xr/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -"""iAlarmXR utils.""" -import logging - -from pyialarmxr import IAlarmXR - -from homeassistant import core -from homeassistant.helpers.device_registry import format_mac - -_LOGGER = logging.getLogger(__name__) - - -async def async_get_ialarmxr_mac(hass: core.HomeAssistant, ialarmxr: IAlarmXR) -> str: - """Retrieve iAlarmXR MAC address.""" - _LOGGER.debug("Retrieving ialarmxr mac address") - - mac = await hass.async_add_executor_job(ialarmxr.get_mac) - - return format_mac(mac) diff --git a/homeassistant/components/iaqualink/translations/sv.json b/homeassistant/components/iaqualink/translations/sv.json index 0e086c9c413..e697b5a02b9 100644 --- a/homeassistant/components/iaqualink/translations/sv.json +++ b/homeassistant/components/iaqualink/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/icloud/translations/bg.json b/homeassistant/components/icloud/translations/bg.json index 52c6ed9b018..4ee0a45d19d 100644 --- a/homeassistant/components/icloud/translations/bg.json +++ b/homeassistant/components/icloud/translations/bg.json @@ -8,6 +8,11 @@ "validate_verification_code": "\u041f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0430\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043a\u043e\u0434\u0430 \u0437\u0430 \u043f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0435\u043d\u0438\u0435 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e" }, "step": { + "reauth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index ebab2592403..8bd267891a6 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -127,6 +127,7 @@ def setup_platform( class IFTTTAlarmPanel(AlarmControlPanelEntity): """Representation of an alarm control panel controlled through IFTTT.""" + _attr_assumed_state = True _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -145,7 +146,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): optimistic, ): """Initialize the alarm control panel.""" - self._name = name + self._attr_name = name self._code = code self._code_arm_required = code_arm_required self._event_away = event_away @@ -153,25 +154,9 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): self._event_night = event_night self._event_disarm = event_disarm self._optimistic = optimistic - self._state = None @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def assumed_state(self): - """Notify that this platform return an assumed state.""" - return True - - @property - def code_format(self): + def code_format(self) -> CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None @@ -179,25 +164,25 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): return CodeFormat.NUMBER return CodeFormat.TEXT - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._check_code(code): return self.set_alarm_state(self._event_disarm, STATE_ALARM_DISARMED) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if self._code_arm_required and not self._check_code(code): return self.set_alarm_state(self._event_away, STATE_ALARM_ARMED_AWAY) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if self._code_arm_required and not self._check_code(code): return self.set_alarm_state(self._event_home, STATE_ALARM_ARMED_HOME) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" if self._code_arm_required and not self._check_code(code): return @@ -210,13 +195,13 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): self.hass.services.call(DOMAIN, SERVICE_TRIGGER, data) _LOGGER.debug("Called IFTTT integration to trigger event %s", event) if self._optimistic: - self._state = state + self._attr_state = state def push_alarm_state(self, value): """Push the alarm state to the given value.""" if value in ALLOWED_STATES: _LOGGER.debug("Pushed the alarm state to %s", value) - self._state = value + self._attr_state = value - def _check_code(self, code): + def _check_code(self, code: str | None) -> bool: return self._code is None or self._code == code diff --git a/homeassistant/components/ifttt/translations/es.json b/homeassistant/components/ifttt/translations/es.json index b29862c38e2..e65f12b6295 100644 --- a/homeassistant/components/ifttt/translations/es.json +++ b/homeassistant/components/ifttt/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cloud_not_connected": "No est\u00e1 conectado a Home Assistant Cloud.", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 655590005bf..f4bbadfa6ac 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -2,7 +2,7 @@ "domain": "imap", "name": "IMAP", "documentation": "https://www.home-assistant.io/integrations/imap", - "requirements": ["aioimaplib==0.9.0"], + "requirements": ["aioimaplib==1.0.0"], "codeowners": [], "iot_class": "cloud_push", "loggers": ["aioimaplib"] diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index a6ee8dd0f7d..8e922687e59 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -1,6 +1,7 @@ """Support to set a numeric value from a slider or text box.""" from __future__ import annotations +from contextlib import suppress import logging import voluptuous as vol @@ -281,8 +282,10 @@ class InputNumber(RestoreEntity): if self._current_value is not None: return - state = await self.async_get_last_state() - value = state and float(state.state) + value: float | None = None + if state := await self.async_get_last_state(): + with suppress(ValueError): + value = float(state.state) # Check against None because value can be 0 if value is not None and self._minimum <= value <= self._maximum: diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index be68a66b70a..d9261a65c32 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -119,7 +119,9 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> InsteonOptionsFlowHandler: """Define the config flow to handle options.""" return InsteonOptionsFlowHandler(config_entry) @@ -234,7 +236,7 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class InsteonOptionsFlowHandler(config_entries.OptionsFlow): """Handle an Insteon options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Init the InsteonOptionsFlowHandler class.""" self.config_entry = config_entry diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index defa1acaa38..645450166b9 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -1,5 +1,6 @@ """Support for Insteon covers via PowerLinc Modem.""" import math +from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, @@ -46,7 +47,7 @@ class InsteonCoverEntity(InsteonEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current cover position.""" if self._insteon_device_group.value is not None: pos = self._insteon_device_group.value @@ -55,19 +56,19 @@ class InsteonCoverEntity(InsteonEntity, CoverEntity): return int(math.ceil(pos * 100 / 255)) @property - def is_closed(self): + def is_closed(self) -> bool: """Return the boolean response if the node is on.""" return bool(self.current_cover_position) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" await self._insteon_device.async_open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._insteon_device.async_close() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover position.""" position = int(kwargs[ATTR_POSITION] * 255 / 100) if position == 0: diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 8639dfb79fe..c7512ba0278 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations import math +from typing import Any from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, @@ -62,14 +63,14 @@ class InsteonFanEntity(InsteonEntity, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.async_set_percentage(percentage or 67) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" await self._insteon_device.async_fan_off() diff --git a/homeassistant/components/insteon/translations/sv.json b/homeassistant/components/insteon/translations/sv.json index b8b6834022c..4992e704f9e 100644 --- a/homeassistant/components/insteon/translations/sv.json +++ b/homeassistant/components/insteon/translations/sv.json @@ -2,6 +2,22 @@ "config": { "abort": { "not_insteon_device": "Uppt\u00e4ckt enhet \u00e4r inte en Insteon-enhet" + }, + "step": { + "hubv2": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "change_hub_config": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/integration/translations/bg.json b/homeassistant/components/integration/translations/bg.json index 35cfa0ad1d7..ac35010224f 100644 --- a/homeassistant/components/integration/translations/bg.json +++ b/homeassistant/components/integration/translations/bg.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "method": "\u041c\u0435\u0442\u043e\u0434 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435", "name": "\u0418\u043c\u0435" } } diff --git a/homeassistant/components/integration/translations/es.json b/homeassistant/components/integration/translations/es.json index 8034e4746fe..4b4f1306dc9 100644 --- a/homeassistant/components/integration/translations/es.json +++ b/homeassistant/components/integration/translations/es.json @@ -4,6 +4,7 @@ "user": { "data": { "method": "M\u00e9todo de integraci\u00f3n", + "name": "Nombre", "round": "Precisi\u00f3n", "source": "Sensor de entrada", "unit_prefix": "Prefijo m\u00e9trico", @@ -13,7 +14,9 @@ "round": "Controla el n\u00famero de d\u00edgitos decimales en la salida.", "unit_prefix": "La salida se escalar\u00e1 seg\u00fan el prefijo m\u00e9trico seleccionado.", "unit_time": "La salida se escalar\u00e1 seg\u00fan la unidad de tiempo seleccionada." - } + }, + "description": "Cree un sensor que calcule una suma de Riemann para estimar la integral de un sensor.", + "title": "A\u00f1adir sensor integral de suma de Riemann" } } }, diff --git a/homeassistant/components/integration/translations/he.json b/homeassistant/components/integration/translations/he.json index 219c75605bb..b4aa653fd20 100644 --- a/homeassistant/components/integration/translations/he.json +++ b/homeassistant/components/integration/translations/he.json @@ -8,13 +8,13 @@ "round": "\u05d3\u05d9\u05d5\u05e7", "source": "\u05d7\u05d9\u05d9\u05e9\u05df \u05e7\u05dc\u05d8", "unit_prefix": "\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05de\u05d8\u05e8\u05d9\u05ea", - "unit_time": "\u05d6\u05de\u05df \u05e9\u05d9\u05dc\u05d5\u05d1" + "unit_time": "\u05d9\u05d7\u05d9\u05d3\u05ea \u05d6\u05de\u05df" }, "data_description": { "round": "\u05e9\u05dc\u05d9\u05d8\u05d4 \u05d1\u05de\u05e1\u05e4\u05e8 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05d4\u05e2\u05e9\u05e8\u05d5\u05e0\u05d9\u05d5\u05ea \u05d1\u05e4\u05dc\u05d8." }, - "description": "\u05d3\u05d9\u05d5\u05e7 \u05e9\u05d5\u05dc\u05d8 \u05d1\u05de\u05e1\u05e4\u05e8 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05d4\u05e2\u05e9\u05e8\u05d5\u05e0\u05d9\u05d5\u05ea \u05d1\u05e4\u05dc\u05d8.\n\u05d4\u05e1\u05db\u05d5\u05dd \u05d9\u05e9\u05ea\u05e0\u05d4 \u05d1\u05d4\u05ea\u05d0\u05dd \u05dc\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05d4\u05de\u05d8\u05e8\u05d9\u05ea \u05e9\u05e0\u05d1\u05d7\u05e8\u05d4 \u05d5\u05d6\u05de\u05df \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.", - "title": "\u05d7\u05d9\u05d9\u05e9\u05df \u05e9\u05d9\u05dc\u05d5\u05d1 \u05d7\u05d3\u05e9" + "description": "\u05e6\u05d5\u05e8 \u05d7\u05d9\u05d9\u05e9\u05df \u05d4\u05de\u05d7\u05e9\u05d1 \u05e1\u05db\u05d5\u05dd Riemann \u05db\u05d3\u05d9 \u05dc\u05d4\u05e2\u05e8\u05d9\u05da \u05d0\u05ea \u05d4\u05d0\u05d9\u05e0\u05d8\u05d2\u05e8\u05dc \u05e9\u05dc \u05d7\u05d9\u05d9\u05e9\u05df.", + "title": "\u05d4\u05d5\u05e1\u05e3 \u05d7\u05d9\u05d9\u05e9\u05df \u05e1\u05db\u05d5\u05dd \u05d0\u05d9\u05e0\u05d8\u05d2\u05e8\u05dc\u05d9 \u05e9\u05dc Riemann" } } }, diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 83c6e05f572..034e74c2aa6 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -2,15 +2,22 @@ from __future__ import annotations from aiohttp import ClientConnectionError -from intellifire4py import IntellifireAsync, IntellifireControlAsync +from intellifire4py import IntellifireControlAsync from intellifire4py.exceptions import LoginException +from intellifire4py.intellifire import IntellifireAPICloud, IntellifireAPILocal from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import DOMAIN, LOGGER +from .const import CONF_USER_ID, DOMAIN, LOGGER from .coordinator import IntellifireDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] @@ -24,8 +31,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Old config entry format detected: %s", entry.unique_id) raise ConfigEntryAuthFailed - # Define the API Objects - read_object = IntellifireAsync(entry.data[CONF_HOST]) ift_control = IntellifireControlAsync( fireplace_ip=entry.data[CONF_HOST], ) @@ -42,9 +47,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: finally: await ift_control.close() + # Extract API Key and User_ID from ift_control + # Eventually this will migrate to using IntellifireAPICloud + + if CONF_USER_ID not in entry.data or CONF_API_KEY not in entry.data: + LOGGER.info( + "Updating intellifire config entry for %s with api information", + entry.unique_id, + ) + cloud_api = IntellifireAPICloud() + await cloud_api.login( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + api_key = cloud_api.get_fireplace_api_key() + user_id = cloud_api.get_user_id() + # Update data entry + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_API_KEY: api_key, + CONF_USER_ID: user_id, + }, + ) + + else: + api_key = entry.data[CONF_API_KEY] + user_id = entry.data[CONF_USER_ID] + + # Instantiate local control + api = IntellifireAPILocal( + fireplace_ip=entry.data[CONF_HOST], + api_key=api_key, + user_id=user_id, + ) + # Define the update coordinator coordinator = IntellifireDataUpdateCoordinator( - hass=hass, read_api=read_object, control_api=ift_control + hass=hass, + api=api, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index 6066d703729..4556668b702 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -1,24 +1,22 @@ """Config flow for IntelliFire integration.""" from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Any from aiohttp import ClientConnectionError -from intellifire4py import ( - AsyncUDPFireplaceFinder, - IntellifireAsync, - IntellifireControlAsync, -) +from intellifire4py import AsyncUDPFireplaceFinder from intellifire4py.exceptions import LoginException +from intellifire4py.intellifire import IntellifireAPICloud, IntellifireAPILocal import voluptuous as vol from homeassistant import config_entries from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN, LOGGER +from .const import CONF_USER_ID, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -33,14 +31,16 @@ class DiscoveredHostInfo: serial: str | None -async def validate_host_input(host: str) -> str: +async def validate_host_input(host: str, dhcp_mode: bool = False) -> str: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = IntellifireAsync(host) - await api.poll() + LOGGER.debug("Instantiating IntellifireAPI with host: [%s]", host) + api = IntellifireAPILocal(fireplace_ip=host) + await api.poll(supress_warnings=dhcp_mode) serial = api.data.serial + LOGGER.debug("Found a fireplace: %s", serial) # Return the serial number which will be used to calculate a unique ID for the device/sensors return serial @@ -82,17 +82,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, *, host: str, username: str, password: str, serial: str ): """Validate username/password against api.""" - ift_control = IntellifireControlAsync(fireplace_ip=host) - LOGGER.debug("Attempting login to iftapi with: %s", username) - # This can throw an error which will be handled above - try: - await ift_control.login(username=username, password=password) - await ift_control.get_username() - finally: - await ift_control.close() - data = {CONF_HOST: host, CONF_PASSWORD: password, CONF_USERNAME: username} + ift_cloud = IntellifireAPICloud() + await ift_cloud.login(username=username, password=password) + api_key = ift_cloud.get_fireplace_api_key() + user_id = ift_cloud.get_user_id() + + data = { + CONF_HOST: host, + CONF_PASSWORD: password, + CONF_USERNAME: username, + CONF_API_KEY: api_key, + CONF_USER_ID: user_id, + } # Update or Create existing_entry = await self.async_set_unique_id(serial) @@ -220,10 +223,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("Running Step: manual_device_entry") return await self.async_step_manual_device_entry() - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" LOGGER.debug("STEP: reauth") entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + assert entry.unique_id # populate the expected vars self._serial = entry.unique_id @@ -236,14 +241,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Handle DHCP Discovery.""" - LOGGER.debug("STEP: dhcp") # Run validation logic on ip host = discovery_info.ip + LOGGER.debug("STEP: dhcp for host %s", host) self._async_abort_entries_match({CONF_HOST: host}) try: - self._serial = await validate_host_input(host) + self._serial = await validate_host_input(host, dhcp_mode=True) except (ConnectionError, ClientConnectionError): + LOGGER.debug( + "DHCP Discovery has determined %s is not an IntelliFire device", host + ) return self.async_abort(reason="not_intellifire_device") await self.async_set_unique_id(self._serial) diff --git a/homeassistant/components/intellifire/const.py b/homeassistant/components/intellifire/const.py index fe715c3ce8a..2e9a2fabc06 100644 --- a/homeassistant/components/intellifire/const.py +++ b/homeassistant/components/intellifire/const.py @@ -5,6 +5,8 @@ import logging DOMAIN = "intellifire" +CONF_USER_ID = "user_id" + LOGGER = logging.getLogger(__package__) CONF_SERIAL = "serial" diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index 9b74bd81653..39f197285d4 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -5,11 +5,8 @@ from datetime import timedelta from aiohttp import ClientConnectionError from async_timeout import timeout -from intellifire4py import ( - IntellifireAsync, - IntellifireControlAsync, - IntellifirePollData, -) +from intellifire4py import IntellifirePollData +from intellifire4py.intellifire import IntellifireAPILocal from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo @@ -24,8 +21,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData def __init__( self, hass: HomeAssistant, - read_api: IntellifireAsync, - control_api: IntellifireControlAsync, + api: IntellifireAPILocal, ) -> None: """Initialize the Coordinator.""" super().__init__( @@ -34,27 +30,37 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData name=DOMAIN, update_interval=timedelta(seconds=15), ) - self._read_api = read_api - self._control_api = control_api + self._api = api async def _async_update_data(self) -> IntellifirePollData: - LOGGER.debug("Calling update loop on IntelliFire") - async with timeout(100): - try: - await self._read_api.poll() - except (ConnectionError, ClientConnectionError) as exception: - raise UpdateFailed from exception - return self._read_api.data + + if not self._api.is_polling_in_background: + LOGGER.info("Starting Intellifire Background Polling Loop") + await self._api.start_background_polling() + + # Don't return uninitialized poll data + async with timeout(15): + try: + await self._api.poll() + except (ConnectionError, ClientConnectionError) as exception: + raise UpdateFailed from exception + + LOGGER.info("Failure Count %d", self._api.failed_poll_attempts) + if self._api.failed_poll_attempts > 10: + LOGGER.debug("Too many polling errors - raising exception") + raise UpdateFailed + + return self._api.data @property - def read_api(self) -> IntellifireAsync: + def read_api(self) -> IntellifireAPILocal: """Return the Status API pointer.""" - return self._read_api + return self._api @property - def control_api(self) -> IntellifireControlAsync: + def control_api(self) -> IntellifireAPILocal: """Return the control API.""" - return self._control_api + return self._api @property def device_info(self) -> DeviceInfo: @@ -65,5 +71,5 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData name="IntelliFire Fireplace", identifiers={("IntelliFire", f"{self.read_api.data.serial}]")}, sw_version=self.read_api.data.fw_ver_str, - configuration_url=f"http://{self.read_api.ip}/poll", + configuration_url=f"http://{self._api.fireplace_ip}/poll", ) diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 388ce0c86cb..e2ae4bb8abe 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -3,7 +3,7 @@ "name": "IntelliFire", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/intellifire", - "requirements": ["intellifire4py==1.0.2"], + "requirements": ["intellifire4py==2.0.1"], "codeowners": ["@jeeftor"], "iot_class": "local_polling", "loggers": ["intellifire4py"], diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 9c196a59fd4..ef0363696c4 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -5,7 +5,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from intellifire4py import IntellifireControlAsync, IntellifirePollData +from intellifire4py import IntellifirePollData +from intellifire4py.intellifire import IntellifireAPILocal from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -21,8 +22,8 @@ from .entity import IntellifireEntity class IntellifireSwitchRequiredKeysMixin: """Mixin for required keys.""" - on_fn: Callable[[IntellifireControlAsync], Awaitable] - off_fn: Callable[[IntellifireControlAsync], Awaitable] + on_fn: Callable[[IntellifireAPILocal], Awaitable] + off_fn: Callable[[IntellifireAPILocal], Awaitable] value_fn: Callable[[IntellifirePollData], bool] @@ -37,24 +38,16 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( IntellifireSwitchEntityDescription( key="on_off", name="Flame", - on_fn=lambda control_api: control_api.flame_on( - fireplace=control_api.default_fireplace - ), - off_fn=lambda control_api: control_api.flame_off( - fireplace=control_api.default_fireplace - ), + on_fn=lambda control_api: control_api.flame_on(), + off_fn=lambda control_api: control_api.flame_off(), value_fn=lambda data: data.is_on, ), IntellifireSwitchEntityDescription( key="pilot", name="Pilot Light", icon="mdi:fire-alert", - on_fn=lambda control_api: control_api.pilot_on( - fireplace=control_api.default_fireplace - ), - off_fn=lambda control_api: control_api.pilot_off( - fireplace=control_api.default_fireplace - ), + on_fn=lambda control_api: control_api.pilot_on(), + off_fn=lambda control_api: control_api.pilot_off(), value_fn=lambda data: data.pilot_on, ), ) @@ -82,10 +75,12 @@ class IntellifireSwitch(IntellifireEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" await self.entity_description.on_fn(self.coordinator.control_api) + await self.async_update_ha_state(force_refresh=True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" await self.entity_description.off_fn(self.coordinator.control_api) + await self.async_update_ha_state(force_refresh=True) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/intellifire/translations/es.json b/homeassistant/components/intellifire/translations/es.json index 8d19b2ba3bf..4b61f7f4b3e 100644 --- a/homeassistant/components/intellifire/translations/es.json +++ b/homeassistant/components/intellifire/translations/es.json @@ -24,7 +24,8 @@ "manual_device_entry": { "data": { "host": "Host (direcci\u00f3n IP)" - } + }, + "description": "Configuraci\u00f3n local" }, "pick_device": { "data": { diff --git a/homeassistant/components/intellifire/translations/he.json b/homeassistant/components/intellifire/translations/he.json index 18cf71bf358..bb6f3c30c76 100644 --- a/homeassistant/components/intellifire/translations/he.json +++ b/homeassistant/components/intellifire/translations/he.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, + "flow_title": "{serial} ({host})", "step": { "api_config": { "data": { @@ -16,7 +17,7 @@ }, "manual_device_entry": { "data": { - "host": "\u05de\u05d0\u05e8\u05d7" + "host": "\u05de\u05d0\u05e8\u05d7 (\u05db\u05ea\u05d5\u05d1\u05ea IP)" } }, "pick_device": { diff --git a/homeassistant/components/intellifire/translations/sv.json b/homeassistant/components/intellifire/translations/sv.json index be36fec5fe3..afffc97862b 100644 --- a/homeassistant/components/intellifire/translations/sv.json +++ b/homeassistant/components/intellifire/translations/sv.json @@ -5,6 +5,13 @@ }, "error": { "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "api_config": { + "data": { + "username": "E-postadress" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index d14aaf5a68b..e8c5c580708 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -99,11 +99,13 @@ class ScriptIntentHandler(intent.IntentHandler): speech[CONF_TYPE], ) - if reprompt is not None and reprompt[CONF_TEXT].template: - response.async_set_reprompt( - reprompt[CONF_TEXT].async_render(slots, parse_result=False), - reprompt[CONF_TYPE], - ) + if reprompt is not None: + text_reprompt = reprompt[CONF_TEXT].async_render(slots, parse_result=False) + if text_reprompt: + response.async_set_reprompt( + text_reprompt, + reprompt[CONF_TYPE], + ) if card is not None: response.async_set_card( diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 0f7fe6b33ca..050bed8c721 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -40,13 +40,14 @@ _LOGGER = logging.getLogger(__name__) IH_DEVICE_INTESISHOME = "IntesisHome" IH_DEVICE_AIRCONWITHME = "airconwithme" +IH_DEVICE_ANYWAIR = "anywair" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_DEVICE, default=IH_DEVICE_INTESISHOME): vol.In( - [IH_DEVICE_AIRCONWITHME, IH_DEVICE_INTESISHOME] + [IH_DEVICE_AIRCONWITHME, IH_DEVICE_ANYWAIR, IH_DEVICE_INTESISHOME] ), } ) diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json index 6b84f735c12..d4ec7f6d744 100644 --- a/homeassistant/components/intesishome/manifest.json +++ b/homeassistant/components/intesishome/manifest.json @@ -3,7 +3,7 @@ "name": "IntesisHome", "documentation": "https://www.home-assistant.io/integrations/intesishome", "codeowners": ["@jnimmo"], - "requirements": ["pyintesishome==1.7.6"], + "requirements": ["pyintesishome==1.8.0"], "iot_class": "cloud_push", "loggers": ["pyintesishome"] } diff --git a/homeassistant/components/iotawatt/translations/sv.json b/homeassistant/components/iotawatt/translations/sv.json new file mode 100644 index 00000000000..d1d69759ba6 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "auth": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 731c3d7fb60..dd585b88802 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -25,12 +25,12 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity, ) @@ -40,6 +40,8 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MODE, CONF_NAME, + PRESSURE_HPA, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback @@ -174,6 +176,10 @@ async def async_get_location(hass, api, latitude, longitude): class IPMAWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, location: Location, api: IPMA_API, config): """Initialise the platform with a data instance and station name.""" self._api = api @@ -237,7 +243,7 @@ class IPMAWeather(WeatherEntity): ) @property - def temperature(self): + def native_temperature(self): """Return the current temperature.""" if not self._observation: return None @@ -245,7 +251,7 @@ class IPMAWeather(WeatherEntity): return self._observation.temperature @property - def pressure(self): + def native_pressure(self): """Return the current pressure.""" if not self._observation: return None @@ -261,7 +267,7 @@ class IPMAWeather(WeatherEntity): return self._observation.humidity @property - def wind_speed(self): + def native_wind_speed(self): """Return the current windspeed.""" if not self._observation: return None @@ -276,11 +282,6 @@ class IPMAWeather(WeatherEntity): return self._observation.wind_direction - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def forecast(self): """Return the forecast array.""" @@ -307,13 +308,13 @@ class IPMAWeather(WeatherEntity): ), None, ), - ATTR_FORECAST_TEMP: float(data_in.feels_like_temperature), + ATTR_FORECAST_NATIVE_TEMP: float(data_in.feels_like_temperature), ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( int(float(data_in.precipitation_probability)) if int(float(data_in.precipitation_probability)) >= 0 else None ), - ATTR_FORECAST_WIND_SPEED: data_in.wind_strength, + ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, } for data_in in forecast_filtered @@ -331,10 +332,10 @@ class IPMAWeather(WeatherEntity): ), None, ), - ATTR_FORECAST_TEMP_LOW: data_in.min_temperature, - ATTR_FORECAST_TEMP: data_in.max_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.min_temperature, + ATTR_FORECAST_NATIVE_TEMP: data_in.max_temperature, ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.precipitation_probability, - ATTR_FORECAST_WIND_SPEED: data_in.wind_strength, + ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, } for data_in in forecast_filtered diff --git a/homeassistant/components/ipp/translations/bg.json b/homeassistant/components/ipp/translations/bg.json index d454fe68170..19680bdcfb4 100644 --- a/homeassistant/components/ipp/translations/bg.json +++ b/homeassistant/components/ipp/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 9bb07157b54..7485ff9d608 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.21.6", "pyiqvia==2022.04.0"], + "requirements": ["numpy==1.23.0", "pyiqvia==2022.04.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "loggers": ["pyiqvia"] diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 51f2969e9fe..d8c7ea317c8 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -161,7 +161,7 @@ def calculate_trend(indices: list[float]) -> str: """Calculate the "moving average" of a set of indices.""" index_range = np.arange(0, len(indices)) index_array = np.array(indices) - linear_fit = np.polyfit(index_range, index_array, 1) # type: ignore[no-untyped-call] + linear_fit = np.polyfit(index_range, index_array, 1) slope = round(linear_fit[0], 2) if slope > 0: diff --git a/homeassistant/components/iqvia/translations/sv.json b/homeassistant/components/iqvia/translations/sv.json index 71eb118a858..f6500296b58 100644 --- a/homeassistant/components/iqvia/translations/sv.json +++ b/homeassistant/components/iqvia/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad" + }, "error": { "invalid_zip_code": "Ogiltigt postnummer" }, diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index c88e26e1c90..406eaf23670 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -4,63 +4,30 @@ import logging from prayer_times_calculator import PrayerTimesCalculator, exceptions from requests.exceptions import ConnectionError as ConnError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_point_in_time -from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -from .const import ( - CALC_METHODS, - CONF_CALC_METHOD, - DATA_UPDATED, - DEFAULT_CALC_METHOD, - DOMAIN, -) +from .const import CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: { - vol.Optional(CONF_CALC_METHOD, default=DEFAULT_CALC_METHOD): vol.In( - CALC_METHODS - ), - } - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Islamic Prayer component from config.""" - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" client = IslamicPrayerClient(hass, config_entry) - if not await client.async_setup(): - return False + await client.async_setup() hass.data.setdefault(DOMAIN, client) return True diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 9963423131c..5278750d36e 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Islamic Prayer Times integration.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -14,7 +16,9 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> IslamicPrayerOptionsFlowHandler: """Get the options flow for this handler.""" return IslamicPrayerOptionsFlowHandler(config_entry) @@ -28,15 +32,11 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=NAME, data=user_input) - async def async_step_import(self, import_config): - """Import from config.""" - return await self.async_step_user(user_input=import_config) - class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): """Handle Islamic Prayer client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/iss/translations/es.json b/homeassistant/components/iss/translations/es.json index 6ca3e875615..02a03ee5995 100644 --- a/homeassistant/components/iss/translations/es.json +++ b/homeassistant/components/iss/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "latitude_longitude_not_defined": "La latitud y la longitud no est\u00e1n definidas en Home Assistant." + "latitude_longitude_not_defined": "La latitud y la longitud no est\u00e1n definidas en Home Assistant.", + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." }, "step": { "user": { diff --git a/homeassistant/components/iss/translations/pt-BR.json b/homeassistant/components/iss/translations/pt-BR.json index 6618abe68f4..af3f4d37147 100644 --- a/homeassistant/components/iss/translations/pt-BR.json +++ b/homeassistant/components/iss/translations/pt-BR.json @@ -14,7 +14,7 @@ "step": { "init": { "data": { - "show_on_map": "[%key:component::iss::config::step::user::data::show_on_map%]" + "show_on_map": "Mostrar no mapa" } } } diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e94b8215746..3cee445b587 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -45,6 +45,7 @@ from .const import ( ) from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables from .services import async_setup_services, async_unload_services +from .util import unique_ids_for_config_entry_id CONFIG_SCHEMA = vol.Schema( { @@ -296,3 +297,15 @@ async def async_unload_entry( async_unload_services(hass) return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove isy994 config entry from a device.""" + return not device_entry.identifiers.intersection( + (DOMAIN, unique_id) + for unique_id in unique_ids_for_config_entry_id(hass, config_entry.entry_id) + ) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 34d4738db68..0dab84878b0 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Universal Devices ISY994 integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse, urlunparse @@ -12,10 +13,11 @@ from pyisy.configuration import Configuration from pyisy.connection import Connection import voluptuous as vol -from homeassistant import config_entries, core, data_entry_flow, exceptions +from homeassistant import config_entries, core, exceptions from homeassistant.components import dhcp, ssdp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import aiohttp_client from .const import ( @@ -131,7 +133,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle the initial step.""" errors = {} info: dict[str, str] = {} @@ -159,9 +161,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import( - self, user_input: dict[str, Any] - ) -> data_entry_flow.FlowResult: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle import.""" return await self.async_step_user(user_input) @@ -173,7 +173,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not existing_entry: return if existing_entry.source == config_entries.SOURCE_IGNORE: - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") parsed_url = urlparse(existing_entry.data[CONF_HOST]) if parsed_url.hostname != ip_address: new_netloc = ip_address @@ -197,11 +197,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), }, ) - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle a discovered isy994 via dhcp.""" friendly_name = discovery_info.hostname if friendly_name.startswith("polisy"): @@ -222,9 +220,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() - async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered isy994.""" friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] url = discovery_info.ssdp_location @@ -253,16 +249,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._existing_entry = await self.async_set_unique_id(self.context["unique_id"]) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle reauth input.""" errors = {} assert self._existing_entry is not None @@ -314,7 +308,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index d131a150fb3..f3620ec9663 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -2,7 +2,7 @@ "domain": "isy994", "name": "Universal Devices ISY994", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==3.0.6"], + "requirements": ["pyisy==3.0.7"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 654b65309fc..30b5f121df3 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -21,16 +21,8 @@ from homeassistant.helpers.entity_platform import async_get_platforms import homeassistant.helpers.entity_registry as er from homeassistant.helpers.service import entity_service_call -from .const import ( - _LOGGER, - DOMAIN, - ISY994_ISY, - ISY994_NODES, - ISY994_PROGRAMS, - ISY994_VARIABLES, - PLATFORMS, - PROGRAM_PLATFORMS, -) +from .const import _LOGGER, DOMAIN, ISY994_ISY +from .util import unique_ids_for_config_entry_id # Common Services for All Platforms: SERVICE_SYSTEM_QUERY = "system_query" @@ -282,7 +274,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Remove extra entities that are no longer part of the integration.""" entity_registry = er.async_get(hass) config_ids = [] - current_unique_ids = [] + current_unique_ids: set[str] = set() for config_entry_id in hass.data[DOMAIN]: entries_for_this_config = er.async_entries_for_config_entry( @@ -294,23 +286,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 for entity in entries_for_this_config ] ) - - hass_isy_data = hass.data[DOMAIN][config_entry_id] - uuid = hass_isy_data[ISY994_ISY].configuration["uuid"] - - for platform in PLATFORMS: - for node in hass_isy_data[ISY994_NODES][platform]: - if hasattr(node, "address"): - current_unique_ids.append(f"{uuid}_{node.address}") - - for platform in PROGRAM_PLATFORMS: - for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]: - if hasattr(node, "address"): - current_unique_ids.append(f"{uuid}_{node.address}") - - for node in hass_isy_data[ISY994_VARIABLES]: - if hasattr(node, "address"): - current_unique_ids.append(f"{uuid}_{node.address}") + current_unique_ids |= unique_ids_for_config_entry_id(hass, config_entry_id) extra_entities = [ entity_id diff --git a/homeassistant/components/isy994/translations/es.json b/homeassistant/components/isy994/translations/es.json index b9b2c1f1ed5..7fd765fb437 100644 --- a/homeassistant/components/isy994/translations/es.json +++ b/homeassistant/components/isy994/translations/es.json @@ -7,6 +7,7 @@ "cannot_connect": "Error al conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "invalid_host": "La entrada del host no estaba en formato URL completo, por ejemplo, http://192.168.10.100:80", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa", "unknown": "Error inesperado" }, "flow_title": "{name} ({host})", diff --git a/homeassistant/components/isy994/translations/zh-Hant.json b/homeassistant/components/isy994/translations/zh-Hant.json index c22bbd1b58d..f6625c0bb60 100644 --- a/homeassistant/components/isy994/translations/zh-Hant.json +++ b/homeassistant/components/isy994/translations/zh-Hant.json @@ -17,7 +17,7 @@ "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "{username} \u6191\u8b49\u4e0d\u518d\u6709\u6548\u3002", + "description": "{host} \u6191\u8b49\u4e0d\u518d\u6709\u6548\u3002", "title": "\u91cd\u65b0\u8a8d\u8b49 ISY \u5e33\u865f" }, "user": { diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py new file mode 100644 index 00000000000..196801c58ce --- /dev/null +++ b/homeassistant/components/isy994/util.py @@ -0,0 +1,39 @@ +"""ISY utils.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import ( + DOMAIN, + ISY994_ISY, + ISY994_NODES, + ISY994_PROGRAMS, + ISY994_VARIABLES, + PLATFORMS, + PROGRAM_PLATFORMS, +) + + +def unique_ids_for_config_entry_id( + hass: HomeAssistant, config_entry_id: str +) -> set[str]: + """Find all the unique ids for a config entry id.""" + hass_isy_data = hass.data[DOMAIN][config_entry_id] + uuid = hass_isy_data[ISY994_ISY].configuration["uuid"] + current_unique_ids: set[str] = {uuid} + + for platform in PLATFORMS: + for node in hass_isy_data[ISY994_NODES][platform]: + if hasattr(node, "address"): + current_unique_ids.add(f"{uuid}_{node.address}") + + for platform in PROGRAM_PLATFORMS: + for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]: + if hasattr(node, "address"): + current_unique_ids.add(f"{uuid}_{node.address}") + + for node in hass_isy_data[ISY994_VARIABLES]: + if hasattr(node, "address"): + current_unique_ids.add(f"{uuid}_{node.address}") + + return current_unique_ids diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index d8379859e54..1f679fd43c8 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -7,7 +7,6 @@ DOMAIN: Final = "jellyfin" CLIENT_VERSION: Final = "1.0" COLLECTION_TYPE_MOVIES: Final = "movies" -COLLECTION_TYPE_TVSHOWS: Final = "tvshows" COLLECTION_TYPE_MUSIC: Final = "music" DATA_CLIENT: Final = "client" @@ -24,6 +23,7 @@ ITEM_TYPE_ALBUM: Final = "MusicAlbum" ITEM_TYPE_ARTIST: Final = "MusicArtist" ITEM_TYPE_AUDIO: Final = "Audio" ITEM_TYPE_LIBRARY: Final = "CollectionFolder" +ITEM_TYPE_MOVIE: Final = "Movie" MAX_IMAGE_WIDTH: Final = 500 MAX_STREAMING_BITRATE: Final = "140000000" @@ -33,8 +33,9 @@ MEDIA_SOURCE_KEY_PATH: Final = "Path" MEDIA_TYPE_AUDIO: Final = "Audio" MEDIA_TYPE_NONE: Final = "" +MEDIA_TYPE_VIDEO: Final = "Video" -SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC] +SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC, COLLECTION_TYPE_MOVIES] USER_APP_NAME: Final = "Home Assistant" USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}" diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 993d2520484..48f4cf0c837 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -3,7 +3,7 @@ "name": "Jellyfin", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jellyfin", - "requirements": ["jellyfin-apiclient-python==1.7.2"], + "requirements": ["jellyfin-apiclient-python==1.8.1"], "iot_class": "local_polling", "codeowners": ["@j-stienstra"], "loggers": ["jellyfin_apiclient_python"] diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index dbd79612378..879f4a4d4c8 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import mimetypes from typing import Any -import urllib.parse from jellyfin_apiclient_python.api import jellyfin_url from jellyfin_apiclient_python.client import JellyfinClient @@ -13,6 +12,7 @@ from homeassistant.components.media_player.const import ( MEDIA_CLASS_ALBUM, MEDIA_CLASS_ARTIST, MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_MOVIE, MEDIA_CLASS_TRACK, ) from homeassistant.components.media_player.errors import BrowseError @@ -25,6 +25,7 @@ from homeassistant.components.media_source.models import ( from homeassistant.core import HomeAssistant from .const import ( + COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, DATA_CLIENT, DOMAIN, @@ -39,11 +40,12 @@ from .const import ( ITEM_TYPE_ARTIST, ITEM_TYPE_AUDIO, ITEM_TYPE_LIBRARY, + ITEM_TYPE_MOVIE, MAX_IMAGE_WIDTH, - MAX_STREAMING_BITRATE, MEDIA_SOURCE_KEY_PATH, MEDIA_TYPE_AUDIO, MEDIA_TYPE_NONE, + MEDIA_TYPE_VIDEO, SUPPORTED_COLLECTION_TYPES, ) @@ -147,6 +149,8 @@ class JellyfinSource(MediaSource): if collection_type == COLLECTION_TYPE_MUSIC: return await self._build_music_library(library, include_children) + if collection_type == COLLECTION_TYPE_MOVIES: + return await self._build_movie_library(library, include_children) raise BrowseError(f"Unsupported collection type {collection_type}") @@ -270,6 +274,55 @@ class JellyfinSource(MediaSource): return result + async def _build_movie_library( + self, library: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single movie library as a browsable media source.""" + library_id = library[ITEM_KEY_ID] + library_name = library[ITEM_KEY_NAME] + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=library_id, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_NONE, + title=library_name, + can_play=False, + can_expand=True, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_MOVIE + result.children = await self._build_movies(library_id) # type: ignore[assignment] + + return result + + async def _build_movies(self, library_id: str) -> list[BrowseMediaSource]: + """Return all movies in the movie library.""" + movies = await self._get_children(library_id, ITEM_TYPE_MOVIE) + movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [self._build_movie(movie) for movie in movies] + + def _build_movie(self, movie: dict[str, Any]) -> BrowseMediaSource: + """Return a single movie as a browsable media source.""" + movie_id = movie[ITEM_KEY_ID] + movie_title = movie[ITEM_KEY_NAME] + mime_type = _media_mime_type(movie) + thumbnail_url = self._get_thumbnail_url(movie) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=movie_id, + media_class=MEDIA_CLASS_MOVIE, + media_content_type=mime_type, + title=movie_title, + can_play=True, + can_expand=False, + thumbnail=thumbnail_url, + ) + + return result + async def _get_children( self, parent_id: str, item_type: str ) -> list[dict[str, Any]]: @@ -279,7 +332,7 @@ class JellyfinSource(MediaSource): "ParentId": parent_id, "IncludeItemTypes": item_type, } - if item_type == ITEM_TYPE_AUDIO: + if item_type in {ITEM_TYPE_AUDIO, ITEM_TYPE_MOVIE}: params["Fields"] = ITEM_KEY_MEDIA_SOURCES result = await self.hass.async_add_executor_job(self.api.user_items, "", params) @@ -298,29 +351,15 @@ class JellyfinSource(MediaSource): def _get_stream_url(self, media_item: dict[str, Any]) -> str: """Return the stream URL for a media item.""" media_type = media_item[ITEM_KEY_MEDIA_TYPE] + item_id = media_item[ITEM_KEY_ID] if media_type == MEDIA_TYPE_AUDIO: - return self._get_audio_stream_url(media_item) + return self.api.audio_url(item_id) # type: ignore[no-any-return] + if media_type == MEDIA_TYPE_VIDEO: + return self.api.video_url(item_id) # type: ignore[no-any-return] raise BrowseError(f"Unsupported media type {media_type}") - def _get_audio_stream_url(self, media_item: dict[str, Any]) -> str: - """Return the stream URL for a music media item.""" - item_id = media_item[ITEM_KEY_ID] - user_id = self.client.config.data["auth.user_id"] - device_id = self.client.config.data["app.device_id"] - api_key = self.client.config.data["auth.token"] - - params = urllib.parse.urlencode( - { - "UserId": user_id, - "DeviceId": device_id, - "api_key": api_key, - "MaxStreamingBitrate": MAX_STREAMING_BITRATE, - } - ) - return f"{self.url}Audio/{item_id}/universal?{params}" - def _media_mime_type(media_item: dict[str, Any]) -> str: """Return the mime type of a media item.""" diff --git a/homeassistant/components/jellyfin/translations/sv.json b/homeassistant/components/jellyfin/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/jellyfin/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index 24b0ba4f42b..c7f444b83e2 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -29,16 +29,16 @@ class JuiceNetNumberEntityDescription( ): """An entity description for a JuiceNetNumber.""" - max_value_key: str | None = None + native_max_value_key: str | None = None NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( JuiceNetNumberEntityDescription( name="Amperage Limit", key="current_charging_amperage_limit", - min_value=6, - max_value_key="max_charging_amperage", - step=1, + native_min_value=6, + native_max_value_key="max_charging_amperage", + native_step=1, setter_key="set_charging_amperage_limit", ), ) @@ -80,19 +80,19 @@ class JuiceNetNumber(JuiceNetDevice, NumberEntity): self._attr_name = f"{self.device.name} {description.name}" @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the value of the entity.""" return getattr(self.device, self.entity_description.key, None) @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" - if self.entity_description.max_value_key is not None: - return getattr(self.device, self.entity_description.max_value_key) - if self.entity_description.max_value is not None: - return self.entity_description.max_value + if self.entity_description.native_max_value_key is not None: + return getattr(self.device, self.entity_description.native_max_value_key) + if self.entity_description.native_max_value is not None: + return self.entity_description.native_max_value return DEFAULT_MAX_VALUE - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await getattr(self.device, self.entity_description.setter_key)(value) diff --git a/homeassistant/components/kaleidescape/translations/es.json b/homeassistant/components/kaleidescape/translations/es.json index 6586a20f202..5cb7047f4f5 100644 --- a/homeassistant/components/kaleidescape/translations/es.json +++ b/homeassistant/components/kaleidescape/translations/es.json @@ -3,8 +3,13 @@ "abort": { "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", + "unknown": "Error inesperado", "unsupported": "Dispositiu no compatible" }, + "error": { + "cannot_connect": "Fallo en la conexi\u00f3n", + "unsupported": "Dispositivo no compatible" + }, "flow_title": "{model} ({name})", "step": { "discovery_confirm": { diff --git a/homeassistant/components/keba/binary_sensor.py b/homeassistant/components/keba/binary_sensor.py index 19f3bd428ec..7997130c90a 100644 --- a/homeassistant/components/keba/binary_sensor.py +++ b/homeassistant/components/keba/binary_sensor.py @@ -1,6 +1,8 @@ """Support for KEBA charging station binary sensors.""" from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN +from . import DOMAIN, KebaHandler async def async_setup_platform( @@ -22,7 +24,7 @@ async def async_setup_platform( if discovery_info is None: return - keba = hass.data[DOMAIN] + keba: KebaHandler = hass.data[DOMAIN] sensors = [ KebaBinarySensor( @@ -60,53 +62,37 @@ async def async_setup_platform( class KebaBinarySensor(BinarySensorEntity): """Representation of a binary sensor of a KEBA charging station.""" - def __init__(self, keba, key, name, entity_type, device_class): + _attr_should_poll = False + + def __init__( + self, + keba: KebaHandler, + key: str, + name: str, + entity_type: str, + device_class: BinarySensorDeviceClass, + ) -> None: """Initialize the KEBA Sensor.""" self._key = key self._keba = keba - self._name = name - self._entity_type = entity_type - self._device_class = device_class - self._is_on = None - self._attributes = {} + self._attributes: dict[str, Any] = {} + + self._attr_device_class = device_class + self._attr_name = f"{keba.device_name} {name}" + self._attr_unique_id = f"{keba.device_id}_{entity_type}" @property - def should_poll(self): - """Deactivate polling. Data updated by KebaHandler.""" - return False - - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return f"{self._keba.device_id}_{self._entity_type}" - - @property - def name(self): - """Return the name of the device.""" - return f"{self._keba.device_name} {self._name}" - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._is_on - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the binary sensor.""" return self._attributes - async def async_update(self): + async def async_update(self) -> None: """Get latest cached states from the device.""" if self._key == "Online": - self._is_on = self._keba.get_value(self._key) + self._attr_is_on = self._keba.get_value(self._key) elif self._key == "Plug": - self._is_on = self._keba.get_value("Plug_plugged") + self._attr_is_on = self._keba.get_value("Plug_plugged") self._attributes["plugged_on_wallbox"] = self._keba.get_value( "Plug_wallbox" ) @@ -114,23 +100,23 @@ class KebaBinarySensor(BinarySensorEntity): self._attributes["plugged_on_EV"] = self._keba.get_value("Plug_EV") elif self._key == "State": - self._is_on = self._keba.get_value("State_on") + self._attr_is_on = self._keba.get_value("State_on") self._attributes["status"] = self._keba.get_value("State_details") self._attributes["max_charging_rate"] = str( self._keba.get_value("Max curr") ) elif self._key == "Tmo FS": - self._is_on = not self._keba.get_value("FS_on") + self._attr_is_on = not self._keba.get_value("FS_on") self._attributes["failsafe_timeout"] = str(self._keba.get_value("Tmo FS")) self._attributes["fallback_current"] = str(self._keba.get_value("Curr FS")) elif self._key == "Authreq": - self._is_on = self._keba.get_value(self._key) == 0 + self._attr_is_on = self._keba.get_value(self._key) == 0 - def update_callback(self): + def update_callback(self) -> None: """Schedule a state update.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add update callback after being added to hass.""" self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keba/lock.py b/homeassistant/components/keba/lock.py index b7563ae9b8e..de8d28d7739 100644 --- a/homeassistant/components/keba/lock.py +++ b/homeassistant/components/keba/lock.py @@ -1,12 +1,14 @@ """Support for KEBA charging station switch.""" from __future__ import annotations +from typing import Any + from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN +from . import DOMAIN, KebaHandler async def async_setup_platform( @@ -19,58 +21,40 @@ async def async_setup_platform( if discovery_info is None: return - keba = hass.data[DOMAIN] + keba: KebaHandler = hass.data[DOMAIN] - sensors = [KebaLock(keba, "Authentication", "authentication")] - async_add_entities(sensors) + locks = [KebaLock(keba, "Authentication", "authentication")] + async_add_entities(locks) class KebaLock(LockEntity): """The entity class for KEBA charging stations switch.""" - def __init__(self, keba, name, entity_type): + _attr_should_poll = False + + def __init__(self, keba: KebaHandler, name: str, entity_type: str) -> None: """Initialize the KEBA switch.""" self._keba = keba - self._name = name - self._entity_type = entity_type - self._state = True + self._attr_is_locked = True + self._attr_name = f"{keba.device_name} {name}" + self._attr_unique_id = f"{keba.device_id}_{entity_type}" - @property - def should_poll(self): - """Deactivate polling. Data updated by KebaHandler.""" - return False - - @property - def unique_id(self): - """Return the unique ID of the lock.""" - return f"{self._keba.device_id}_{self._entity_type}" - - @property - def name(self): - """Return the name of the device.""" - return f"{self._keba.device_name} {self._name}" - - @property - def is_locked(self): - """Return true if lock is locked.""" - return self._state - - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock wallbox.""" await self._keba.async_stop() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock wallbox.""" await self._keba.async_start() - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve on off state from the switch.""" - self._state = self._keba.get_value("Authreq") == 1 + self._attr_is_locked = self._keba.get_value("Authreq") == 1 - def update_callback(self): + def update_callback(self) -> None: """Schedule a state update.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add update callback after being added to hass.""" self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 6309f0b79fb..d35c22905f1 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -109,11 +109,11 @@ class KebaSensor(SensorEntity): self._attributes: dict[str, str] = {} @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the binary sensor.""" return self._attributes - async def async_update(self): + async def async_update(self) -> None: """Get latest cached states from the device.""" self._attr_native_value = self._keba.get_value(self.entity_description.key) @@ -128,10 +128,10 @@ class KebaSensor(SensorEntity): elif self.entity_description.key == "Curr user": self._attributes["max_current_hardware"] = self._keba.get_value("Curr HW") - def update_callback(self): + def update_callback(self) -> None: """Schedule a state update.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add update callback after being added to hass.""" self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keenetic_ndms2/translations/bg.json b/homeassistant/components/keenetic_ndms2/translations/bg.json index 3bebf2d185e..42c3174a4c4 100644 --- a/homeassistant/components/keenetic_ndms2/translations/bg.json +++ b/homeassistant/components/keenetic_ndms2/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "flow_title": "{name} ({host})", "step": { "user": { diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index 3884045c9bc..44dc2bb2521 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from kiwiki import KiwiClient, KiwiException import voluptuous as vol @@ -82,19 +83,19 @@ class KiwiLock(LockEntity): } @property - def name(self): + def name(self) -> str | None: """Return the name of the lock.""" name = self._sensor.get("name") specifier = self._sensor["address"].get("specifier") return name or specifier @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state == STATE_LOCKED @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" return self._device_attrs @@ -104,7 +105,7 @@ class KiwiLock(LockEntity): self._state = STATE_LOCKED self.async_write_ha_state() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" try: diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py index 9c7d48a3de9..8a00a03e673 100644 --- a/homeassistant/components/kmtronic/config_flow.py +++ b/homeassistant/components/kmtronic/config_flow.py @@ -1,4 +1,6 @@ """Config flow for kmtronic integration.""" +from __future__ import annotations + import logging import aiohttp @@ -52,7 +54,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> KMTronicOptionsFlow: """Get the options flow for this handler.""" return KMTronicOptionsFlow(config_entry) @@ -88,7 +92,7 @@ class InvalidAuth(exceptions.HomeAssistantError): class KMTronicOptionsFlow(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/kmtronic/translations/bg.json b/homeassistant/components/kmtronic/translations/bg.json index a84e1c3bfdf..d152ddfcf20 100644 --- a/homeassistant/components/kmtronic/translations/bg.json +++ b/homeassistant/components/kmtronic/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { diff --git a/homeassistant/components/kmtronic/translations/sv.json b/homeassistant/components/kmtronic/translations/sv.json new file mode 100644 index 00000000000..a265d988aaa --- /dev/null +++ b/homeassistant/components/kmtronic/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b8f9bdcbd30..0c34428f0a1 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -3,7 +3,7 @@ "name": "KNX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==0.21.3"], + "requirements": ["xknx==0.21.5"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "quality_scale": "platinum", "iot_class": "local_push", diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 9f12aa4ce24..fbf4db3f5b2 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -7,7 +7,7 @@ from xknx import XKNX from xknx.devices import NumericValue from homeassistant import config_entries -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import RestoreNumber from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_MODE, @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from .const import ( @@ -57,7 +56,7 @@ def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue: ) -class KNXNumber(KnxEntity, NumberEntity, RestoreEntity): +class KNXNumber(KnxEntity, RestoreNumber): """Representation of a KNX number.""" _device: NumericValue @@ -65,39 +64,41 @@ class KNXNumber(KnxEntity, NumberEntity, RestoreEntity): def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize a KNX number.""" super().__init__(_create_numeric_value(xknx, config)) - self._attr_max_value = config.get( + self._attr_native_max_value = config.get( NumberSchema.CONF_MAX, self._device.sensor_value.dpt_class.value_max, ) - self._attr_min_value = config.get( + self._attr_native_min_value = config.get( NumberSchema.CONF_MIN, self._device.sensor_value.dpt_class.value_min, ) self._attr_mode = config[CONF_MODE] - self._attr_step = config.get( + self._attr_native_step = config.get( NumberSchema.CONF_STEP, self._device.sensor_value.dpt_class.resolution, ) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) self._attr_unique_id = str(self._device.sensor_value.group_address) - self._attr_unit_of_measurement = self._device.unit_of_measurement() - self._device.sensor_value.value = max(0, self._attr_min_value) + self._attr_native_unit_of_measurement = self._device.unit_of_measurement() + self._device.sensor_value.value = max(0, self._attr_native_min_value) async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.sensor_value.readable and ( - last_state := await self.async_get_last_state() + if ( + not self._device.sensor_value.readable + and (last_state := await self.async_get_last_state()) + and (last_number_data := await self.async_get_last_number_data()) ): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): - self._device.sensor_value.value = float(last_state.state) + self._device.sensor_value.value = last_number_data.native_value @property - def value(self) -> float: + def native_value(self) -> float: """Return the entity value to represent the entity state.""" # self._device.sensor_value.value is set in __init__ so it is never None return cast(float, self._device.resolve_state()) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self._device.set(value) diff --git a/homeassistant/components/knx/translations/es.json b/homeassistant/components/knx/translations/es.json index 6c8ec3e2eb6..d982d96b330 100644 --- a/homeassistant/components/knx/translations/es.json +++ b/homeassistant/components/knx/translations/es.json @@ -6,17 +6,21 @@ }, "error": { "cannot_connect": "Error al conectar", + "file_not_found": "El archivo `.knxkeys` especificado no se encontr\u00f3 en la ruta config/.storage/knx/", "invalid_individual_address": "El valor no coincide con el patr\u00f3n de direcci\u00f3n KNX individual. 'area.line.device'", - "invalid_ip_address": "Direcci\u00f3n IPv4 inv\u00e1lida." + "invalid_ip_address": "Direcci\u00f3n IPv4 inv\u00e1lida.", + "invalid_signature": "La contrase\u00f1a para descifrar el archivo `.knxkeys` es incorrecta." }, "step": { "manual_tunnel": { "data": { "host": "Host", "local_ip": "IP local de Home Assistant", - "port": "Puerto" + "port": "Puerto", + "tunneling_type": "Tipo de t\u00fanel KNX" }, "data_description": { + "host": "Direcci\u00f3n IP del dispositivo de t\u00fanel KNX/IP.", "local_ip": "D\u00e9jalo en blanco para utilizar el descubrimiento autom\u00e1tico.", "port": "Puerto del dispositivo de tunelizaci\u00f3n KNX/IP.Puerto del dispositivo de tunelizaci\u00f3n KNX/IP." }, @@ -29,29 +33,40 @@ "multicast_group": "El grupo de multidifusi\u00f3n utilizado para el enrutamiento", "multicast_port": "El puerto de multidifusi\u00f3n utilizado para el enrutamiento" }, + "data_description": { + "individual_address": "Direcci\u00f3n KNX que usar\u00e1 Home Assistant, por ejemplo, `0.0.4`", + "local_ip": "D\u00e9jelo en blanco para usar el descubrimiento autom\u00e1tico." + }, "description": "Por favor, configure las opciones de enrutamiento." }, "secure_knxkeys": { "data": { + "knxkeys_filename": "El nombre de su archivo `.knxkeys` (incluyendo la extensi\u00f3n)", "knxkeys_password": "Contrase\u00f1a para descifrar el archivo `.knxkeys`." }, "data_description": { + "knxkeys_filename": "Se espera que el archivo se encuentre en su directorio de configuraci\u00f3n en `.storage/knx/`.\n En el sistema operativo Home Assistant, ser\u00eda `/config/.storage/knx/`\n Ejemplo: `mi_proyecto.knxkeys`", "knxkeys_password": "Se ha definido durante la exportaci\u00f3n del archivo desde ETS.Se ha definido durante la exportaci\u00f3n del archivo desde ETS." }, "description": "Introduce la informaci\u00f3n de tu archivo `.knxkeys`." }, "secure_manual": { "data": { - "user_id": "ID de usuario" + "device_authentication": "Contrase\u00f1a de autenticaci\u00f3n del dispositivo", + "user_id": "ID de usuario", + "user_password": "Contrase\u00f1a del usuario" }, "data_description": { - "user_id": "A menudo, es el n\u00famero del t\u00fanel +1. Por tanto, 'T\u00fanel 2' tendr\u00eda el ID de usuario '3'." + "device_authentication": "Esto se configura en el panel 'IP' de la interfaz en ETS.", + "user_id": "A menudo, es el n\u00famero del t\u00fanel +1. Por tanto, 'T\u00fanel 2' tendr\u00eda el ID de usuario '3'.", + "user_password": "Contrase\u00f1a para la conexi\u00f3n espec\u00edfica del t\u00fanel establecida en el panel de \"Propiedades\" del t\u00fanel en ETS." }, "description": "Introduce la informaci\u00f3n de seguridad IP (IP Secure)." }, "secure_tunneling": { "description": "Selecciona c\u00f3mo quieres configurar KNX/IP Secure.", "menu_options": { + "secure_knxkeys": "Use un archivo `.knxkeys` que contenga claves seguras de IP", "secure_manual": "Configura manualmente las claves de seguridad IP (IP Secure)" } }, @@ -83,13 +98,18 @@ }, "data_description": { "individual_address": "Direcci\u00f3n KNX para utilizar con Home Assistant, ej. `0.0.4`", - "rate_limit": "Telegramas de salida m\u00e1ximos por segundo. \nRecomendado: de 20 a 40" + "local_ip": "Usar `0.0.0.0` para el descubrimiento autom\u00e1tico.", + "multicast_group": "Se usa para el enrutamiento y el descubrimiento. Predeterminado: `224.0.23.12`", + "multicast_port": "Se usa para el enrutamiento y el descubrimiento. Predeterminado: `3671`", + "rate_limit": "Telegramas de salida m\u00e1ximos por segundo. \nRecomendado: de 20 a 40", + "state_updater": "Establece los valores predeterminados para leer los estados del bus KNX. Cuando est\u00e1 deshabilitado, Home Assistant no recuperar\u00e1 activamente estados de entidad del bus KNX. Puede ser anulado por las opciones de entidad `sync_state`." } }, "tunnel": { "data": { "host": "Host", - "port": "Puerto" + "port": "Puerto", + "tunneling_type": "Tipo de t\u00fanel KNX" }, "data_description": { "host": "Direcci\u00f3n IP del dispositivo de tunelizaci\u00f3n KNX/IP.", diff --git a/homeassistant/components/knx/translations/fr.json b/homeassistant/components/knx/translations/fr.json index 803fd71b734..7a1e8e88698 100644 --- a/homeassistant/components/knx/translations/fr.json +++ b/homeassistant/components/knx/translations/fr.json @@ -102,7 +102,7 @@ "multicast_group": "Utilis\u00e9 pour le routage et la d\u00e9couverte. Valeur par d\u00e9faut\u00a0: `224.0.23.12`", "multicast_port": "Utilis\u00e9 pour le routage et la d\u00e9couverte. Valeur par d\u00e9faut\u00a0: `3671`", "rate_limit": "Nombre maximal de t\u00e9l\u00e9grammes sortants par seconde.\nValeur recommand\u00e9e\u00a0: entre 20 et 40", - "state_updater": "Active ou d\u00e9sactive globalement la lecture des \u00e9tats depuis le bus KNX. Lorsqu'elle est d\u00e9sactiv\u00e9e, Home Assistant ne r\u00e9cup\u00e8re pas activement les \u00e9tats depuis le bus KNX et les options d'entit\u00e9 `sync_state` n'ont aucun effet." + "state_updater": "Active ou d\u00e9sactive globalement la lecture des \u00e9tats depuis le bus KNX. Lorsqu'elle est d\u00e9sactiv\u00e9e, Home Assistant ne r\u00e9cup\u00e8re pas activement les \u00e9tats depuis le bus KNX. Peut \u00eatre remplac\u00e9 par les options d'entit\u00e9 `sync_state`." } }, "tunnel": { diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 6e71c09501f..32f37ad2ac2 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -6,7 +6,14 @@ from xknx.devices import Weather as XknxWeather from homeassistant import config_entries from homeassistant.components.weather import WeatherEntity -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, TEMP_CELSIUS, Platform +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + PRESSURE_PA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -68,7 +75,9 @@ class KNXWeather(KnxEntity, WeatherEntity): """Representation of a KNX weather device.""" _device: XknxWeather - _attr_temperature_unit = TEMP_CELSIUS + _attr_native_pressure_unit = PRESSURE_PA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of a KNX sensor.""" @@ -77,19 +86,14 @@ class KNXWeather(KnxEntity, WeatherEntity): self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) @property - def temperature(self) -> float | None: - """Return current temperature.""" + def native_temperature(self) -> float | None: + """Return current temperature in C.""" return self._device.temperature @property - def pressure(self) -> float | None: - """Return current air pressure.""" - # KNX returns pA - HA requires hPa - return ( - self._device.air_pressure / 100 - if self._device.air_pressure is not None - else None - ) + def native_pressure(self) -> float | None: + """Return current air pressure in Pa.""" + return self._device.air_pressure @property def condition(self) -> str: @@ -107,11 +111,6 @@ class KNXWeather(KnxEntity, WeatherEntity): return self._device.wind_bearing @property - def wind_speed(self) -> float | None: - """Return current wind speed in km/h.""" - # KNX only supports wind speed in m/s - return ( - self._device.wind_speed * 3.6 - if self._device.wind_speed is not None - else None - ) + def native_wind_speed(self) -> float | None: + """Return current wind speed in m/s.""" + return self._device.wind_speed diff --git a/homeassistant/components/kodi/translations/sv.json b/homeassistant/components/kodi/translations/sv.json new file mode 100644 index 00000000000..9102efc653e --- /dev/null +++ b/homeassistant/components/kodi/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "credentials": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kodi/translations/zh-Hans.json b/homeassistant/components/kodi/translations/zh-Hans.json index 12915ccdb9b..b76fc3c861c 100644 --- a/homeassistant/components/kodi/translations/zh-Hans.json +++ b/homeassistant/components/kodi/translations/zh-Hans.json @@ -43,8 +43,8 @@ }, "device_automation": { "trigger_type": { - "turn_off": "[entity_name} \u88ab\u8981\u6c42\u5173\u95ed", - "turn_on": "[entity_name} \u88ab\u8981\u6c42\u6253\u5f00" + "turn_off": "{entity_name} \u88ab\u8981\u6c42\u5173\u95ed", + "turn_on": "{entity_name} \u88ab\u8981\u6c42\u6253\u5f00" } } } \ No newline at end of file diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index b6f80035dbe..94a58227c56 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -1,4 +1,6 @@ """Config flow for konnected.io integration.""" +from __future__ import annotations + import asyncio import copy import logging @@ -373,7 +375,9 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Return the Options Flow.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/konnected/translations/he.json b/homeassistant/components/konnected/translations/he.json index f07caab4ee1..622c73e2e61 100644 --- a/homeassistant/components/konnected/translations/he.json +++ b/homeassistant/components/konnected/translations/he.json @@ -26,7 +26,7 @@ "step": { "options_binary": { "data": { - "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + "name": "\u05e9\u05dd" } }, "options_digital": { @@ -39,7 +39,7 @@ }, "options_switch": { "data": { - "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + "name": "\u05e9\u05dd" } } } diff --git a/homeassistant/components/kostal_plenticore/__init__.py b/homeassistant/components/kostal_plenticore/__init__.py index a42ad0a64ff..b431960caef 100644 --- a/homeassistant/components/kostal_plenticore/__init__.py +++ b/homeassistant/components/kostal_plenticore/__init__.py @@ -12,7 +12,7 @@ from .helper import Plenticore _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index ac2ecb44fd5..11bb794f799 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,6 +1,8 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" +from dataclasses import dataclass from typing import NamedTuple +from homeassistant.components.number import NumberEntityDescription from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -16,6 +18,7 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) +from homeassistant.helpers.entity import EntityCategory DOMAIN = "kostal_plenticore" @@ -790,31 +793,54 @@ SENSOR_PROCESS_DATA = [ ), ] -# Defines all entities for settings. -# -# Each entry is defined with a tuple of these values: -# - module id (str) -# - process data id (str) -# - entity name suffix (str) -# - sensor properties (dict) -# - value formatter (str) -SENSOR_SETTINGS_DATA = [ - ( - "devices:local", - "Battery:MinHomeComsumption", - "Battery min Home Consumption", - { - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - }, - "format_round", + +@dataclass +class PlenticoreNumberEntityDescriptionMixin: + """Define an entity description mixin for number entities.""" + + module_id: str + data_id: str + fmt_from: str + fmt_to: str + + +@dataclass +class PlenticoreNumberEntityDescription( + NumberEntityDescription, PlenticoreNumberEntityDescriptionMixin +): + """Describes a Plenticore number entity.""" + + +NUMBER_SETTINGS_DATA = [ + PlenticoreNumberEntityDescription( + key="battery_min_soc", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:battery-negative", + name="Battery min SoC", + native_unit_of_measurement=PERCENTAGE, + native_max_value=100, + native_min_value=5, + native_step=5, + module_id="devices:local", + data_id="Battery:MinSoc", + fmt_from="format_round", + fmt_to="format_round_back", ), - ( - "devices:local", - "Battery:MinSoc", - "Battery min Soc", - {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:battery-negative"}, - "format_round", + PlenticoreNumberEntityDescription( + key="battery_min_home_consumption", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + name="Battery min Home Consumption", + native_unit_of_measurement=POWER_WATT, + native_max_value=38000, + native_min_value=50, + native_step=1, + module_id="devices:local", + data_id="Battery:MinHomeComsumption", + fmt_from="format_round", + fmt_to="format_round_back", ), ] diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index e047c0dafba..c87d96161a4 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import datetime, timedelta import logging +from typing import Any from aiohttp.client_exceptions import ClientError from kostal.plenticore import ( @@ -122,7 +123,7 @@ class DataUpdateCoordinatorMixin: """Base implementation for read and write data.""" async def async_read_data(self, module_id: str, data_id: str) -> list[str, bool]: - """Write settings back to Plenticore.""" + """Read data from Plenticore.""" if (client := self._plenticore.client) is None: return False @@ -138,6 +139,10 @@ class DataUpdateCoordinatorMixin: if (client := self._plenticore.client) is None: return False + _LOGGER.debug( + "Setting value for %s in module %s to %s", self.name, module_id, value + ) + try: await client.set_setting_values(module_id, value) except PlenticoreApiException: @@ -328,7 +333,7 @@ class PlenticoreDataFormatter: } @classmethod - def get_method(cls, name: str) -> callable: + def get_method(cls, name: str) -> Callable[[Any], Any]: """Return a callable formatter of the given name.""" return getattr(cls, name) @@ -340,6 +345,21 @@ class PlenticoreDataFormatter: except (TypeError, ValueError): return state + @staticmethod + def format_round_back(value: float) -> str: + """Return a rounded integer value from a float.""" + try: + if isinstance(value, float) and value.is_integer(): + int_value = int(value) + elif isinstance(value, int): + int_value = value + else: + int_value = round(value) + + return str(int_value) + except (TypeError, ValueError): + return "" + @staticmethod def format_float(state: str) -> int | str: """Return the given state value as float rounded to three decimal places.""" diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py new file mode 100644 index 00000000000..1ad911f6d15 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/number.py @@ -0,0 +1,157 @@ +"""Platform for Kostal Plenticore numbers.""" +from __future__ import annotations + +from abc import ABC +from datetime import timedelta +from functools import partial +import logging + +from kostal.plenticore import SettingsData + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, NUMBER_SETTINGS_DATA, PlenticoreNumberEntityDescription +from .helper import PlenticoreDataFormatter, SettingDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Kostal Plenticore Number entities.""" + plenticore = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + available_settings_data = await plenticore.client.get_settings() + settings_data_update_coordinator = SettingDataUpdateCoordinator( + hass, + _LOGGER, + "Settings Data", + timedelta(seconds=30), + plenticore, + ) + + for description in NUMBER_SETTINGS_DATA: + if ( + description.module_id not in available_settings_data + or description.data_id + not in ( + setting.id for setting in available_settings_data[description.module_id] + ) + ): + _LOGGER.debug( + "Skipping non existing setting data %s/%s", + description.module_id, + description.data_id, + ) + continue + + setting_data = next( + filter( + partial(lambda id, sd: id == sd.id, description.data_id), + available_settings_data[description.module_id], + ) + ) + + entities.append( + PlenticoreDataNumber( + settings_data_update_coordinator, + entry.entry_id, + entry.title, + plenticore.device_info, + description, + setting_data, + ) + ) + + async_add_entities(entities) + + +class PlenticoreDataNumber(CoordinatorEntity, NumberEntity, ABC): + """Representation of a Kostal Plenticore Number entity.""" + + entity_description: PlenticoreNumberEntityDescription + coordinator: SettingDataUpdateCoordinator + + def __init__( + self, + coordinator: SettingDataUpdateCoordinator, + entry_id: str, + platform_name: str, + device_info: DeviceInfo, + description: PlenticoreNumberEntityDescription, + setting_data: SettingsData, + ) -> None: + """Initialize the Plenticore Number entity.""" + super().__init__(coordinator) + + self.entity_description = description + self.entry_id = entry_id + + self._attr_device_info = device_info + self._attr_unique_id = f"{self.entry_id}_{self.module_id}_{self.data_id}" + self._attr_name = f"{platform_name} {description.name}" + self._attr_mode = NumberMode.BOX + + self._formatter = PlenticoreDataFormatter.get_method(description.fmt_from) + self._formatter_back = PlenticoreDataFormatter.get_method(description.fmt_to) + + # overwrite from retrieved setting data + if setting_data.min is not None: + self._attr_native_min_value = self._formatter(setting_data.min) + if setting_data.max is not None: + self._attr_native_max_value = self._formatter(setting_data.max) + + @property + def module_id(self) -> str: + """Return the plenticore module id of this entity.""" + return self.entity_description.module_id + + @property + def data_id(self) -> str: + """Return the plenticore data id for this entity.""" + return self.entity_description.data_id + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self.module_id in self.coordinator.data + and self.data_id in self.coordinator.data[self.module_id] + ) + + async def async_added_to_hass(self) -> None: + """Register this entity on the Update Coordinator.""" + await super().async_added_to_hass() + self.coordinator.start_fetch_data(self.module_id, self.data_id) + + async def async_will_remove_from_hass(self) -> None: + """Unregister this entity from the Update Coordinator.""" + self.coordinator.stop_fetch_data(self.module_id, self.data_id) + await super().async_will_remove_from_hass() + + @property + def native_value(self) -> float | None: + """Return the current value.""" + if self.available: + raw_value = self.coordinator.data[self.module_id][self.data_id] + return self._formatter(raw_value) + + return None + + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + str_value = self._formatter_back(value) + await self.coordinator.async_write_data( + self.module_id, {self.data_id: str_value} + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 0b6b01aca71..5f8fb47e85a 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -14,17 +14,8 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_ENABLED_DEFAULT, - DOMAIN, - SENSOR_PROCESS_DATA, - SENSOR_SETTINGS_DATA, -) -from .helper import ( - PlenticoreDataFormatter, - ProcessDataUpdateCoordinator, - SettingDataUpdateCoordinator, -) +from .const import ATTR_ENABLED_DEFAULT, DOMAIN, SENSOR_PROCESS_DATA +from .helper import PlenticoreDataFormatter, ProcessDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -70,38 +61,6 @@ async def async_setup_entry( ) ) - available_settings_data = await plenticore.client.get_settings() - settings_data_update_coordinator = SettingDataUpdateCoordinator( - hass, - _LOGGER, - "Settings Data", - timedelta(seconds=300), - plenticore, - ) - for module_id, data_id, name, sensor_data, fmt in SENSOR_SETTINGS_DATA: - if module_id not in available_settings_data or data_id not in ( - setting.id for setting in available_settings_data[module_id] - ): - _LOGGER.debug( - "Skipping non existing setting data %s/%s", module_id, data_id - ) - continue - - entities.append( - PlenticoreDataSensor( - settings_data_update_coordinator, - entry.entry_id, - entry.title, - module_id, - data_id, - name, - sensor_data, - PlenticoreDataFormatter.get_method(fmt), - plenticore.device_info, - EntityCategory.DIAGNOSTIC, - ) - ) - async_add_entities(entities) diff --git a/homeassistant/components/kostal_plenticore/translations/bg.json b/homeassistant/components/kostal_plenticore/translations/bg.json new file mode 100644 index 00000000000..23968d0a06a --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/bg.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index d8230863d7c..55a29fec2e7 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -1,6 +1,7 @@ """Config flow for laundrify integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -76,9 +77,7 @@ class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): step_id="init", data_schema=CONFIG_SCHEMA, errors=errors ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + 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() diff --git a/homeassistant/components/laundrify/translations/bg.json b/homeassistant/components/laundrify/translations/bg.json new file mode 100644 index 00000000000..4721ecf584e --- /dev/null +++ b/homeassistant/components/laundrify/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "init": { + "data": { + "code": "\u041a\u043e\u0434 \u0437\u0430 \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 (xxx-xxx)" + } + }, + "reauth_confirm": { + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/laundrify/translations/es.json b/homeassistant/components/laundrify/translations/es.json new file mode 100644 index 00000000000..05c019700bc --- /dev/null +++ b/homeassistant/components/laundrify/translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "error": { + "cannot_connect": "Fallo en la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_format": "Formato no v\u00e1lido. Por favor, especif\u00edquelo como xxx-xxx.", + "unknown": "Error inesperado" + }, + "step": { + "init": { + "data": { + "code": "C\u00f3digo de autenticaci\u00f3n (xxx-xxx)" + }, + "description": "Por favor, introduzca su c\u00f3digo de autenticaci\u00f3n personal que se muestra en la aplicaci\u00f3n Laundrify." + }, + "reauth_confirm": { + "description": "La integraci\u00f3n de laundrify necesita volver a autentificarse.", + "title": "Integraci\u00f3n de la reautenticaci\u00f3n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/laundrify/translations/he.json b/homeassistant/components/laundrify/translations/he.json new file mode 100644 index 00000000000..9c8c4c3b8c8 --- /dev/null +++ b/homeassistant/components/laundrify/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth_confirm": { + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/laundrify/translations/pt-BR.json b/homeassistant/components/laundrify/translations/pt-BR.json index c053ae34fe0..1e69a1b3204 100644 --- a/homeassistant/components/laundrify/translations/pt-BR.json +++ b/homeassistant/components/laundrify/translations/pt-BR.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "single_instance_allowed": "J\u00e1 est\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel." + "single_instance_allowed": "J\u00e1 configurado. Apenas uma configura\u00e7\u00e3o \u00e9 poss\u00edvel." }, "error": { - "cannot_connect": "Falhou ao se conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "invalid_format": "Formato Inv\u00e1lido. Especifique como xxx-xxx.", "unknown": "Erro inesperado" diff --git a/homeassistant/components/laundrify/translations/sv.json b/homeassistant/components/laundrify/translations/sv.json new file mode 100644 index 00000000000..dd7447e847e --- /dev/null +++ b/homeassistant/components/laundrify/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index b0b231cb9e9..d1486fe0d32 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -191,7 +191,7 @@ def async_host_input_received( def _async_fire_access_control_event( hass: HomeAssistant, device: dr.DeviceEntry, address: AddressType, inp: InputType ) -> None: - """Fire access control event (transponder, transmitter, fingerprint).""" + """Fire access control event (transponder, transmitter, fingerprint, codelock).""" event_data = { "segment_id": address[0], "module_id": address[1], diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py index a6fc17759b8..8ae640cf6c2 100644 --- a/homeassistant/components/lcn/device_trigger.py +++ b/homeassistant/components/lcn/device_trigger.py @@ -16,13 +16,14 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, KEY_ACTIONS, SENDKEYS -TRIGGER_TYPES = {"transmitter", "transponder", "fingerprint", "send_keys"} +TRIGGER_TYPES = {"transmitter", "transponder", "fingerprint", "codelock", "send_keys"} LCN_DEVICE_TRIGGER_BASE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES)} ) ACCESS_CONTROL_SCHEMA = {vol.Optional("code"): vol.All(vol.Lower, cv.string)} + TRANSMITTER_SCHEMA = { **ACCESS_CONTROL_SCHEMA, vol.Optional("level"): cv.positive_int, @@ -45,6 +46,7 @@ TYPE_SCHEMAS = { "transmitter": {"extra_fields": vol.Schema(TRANSMITTER_SCHEMA)}, "transponder": {"extra_fields": vol.Schema(ACCESS_CONTROL_SCHEMA)}, "fingerprint": {"extra_fields": vol.Schema(ACCESS_CONTROL_SCHEMA)}, + "codelock": {"extra_fields": vol.Schema(ACCESS_CONTROL_SCHEMA)}, "send_keys": {"extra_fields": vol.Schema(SENDKEYS_SCHEMA)}, } diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 2ed8cb8d1c7..c0e46250c1e 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -4,6 +4,7 @@ "transmitter": "transmitter code received", "transponder": "transponder code received", "fingerprint": "fingerprint code received", + "codelock": "code lock code received", "send_keys": "send keys received" } } diff --git a/homeassistant/components/lcn/translations/de.json b/homeassistant/components/lcn/translations/de.json index b4a731fc1f6..f74b3d25dcf 100644 --- a/homeassistant/components/lcn/translations/de.json +++ b/homeassistant/components/lcn/translations/de.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "Codeschloss Code erhalten", "fingerprint": "Fingerabdruckcode empfangen", "send_keys": "Sende Tasten empfangen", "transmitter": "Sendercode empfangen", diff --git a/homeassistant/components/lcn/translations/en.json b/homeassistant/components/lcn/translations/en.json index ad42b1ffc8f..37f092cbde7 100644 --- a/homeassistant/components/lcn/translations/en.json +++ b/homeassistant/components/lcn/translations/en.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "code lock code received", "fingerprint": "fingerprint code received", "send_keys": "send keys received", "transmitter": "transmitter code received", diff --git a/homeassistant/components/lcn/translations/it.json b/homeassistant/components/lcn/translations/it.json index e42f52b9c62..a39ef71ffd7 100644 --- a/homeassistant/components/lcn/translations/it.json +++ b/homeassistant/components/lcn/translations/it.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "codice di blocco ricevuto", "fingerprint": "codice impronta digitale ricevuto", "send_keys": "invia chiavi ricevute", "transmitter": "codice trasmettitore ricevuto", diff --git a/homeassistant/components/lcn/translations/pt-BR.json b/homeassistant/components/lcn/translations/pt-BR.json index 9898533ea72..7a062fe9578 100644 --- a/homeassistant/components/lcn/translations/pt-BR.json +++ b/homeassistant/components/lcn/translations/pt-BR.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "c\u00f3digo de bloqueio de c\u00f3digo recebido", "fingerprint": "c\u00f3digo de impress\u00e3o digital recebido", "send_keys": "enviar chaves recebidas", "transmitter": "c\u00f3digo do transmissor recebido", diff --git a/homeassistant/components/lg_soundbar/__init__.py b/homeassistant/components/lg_soundbar/__init__.py index 175153556f9..75b2109b22a 100644 --- a/homeassistant/components/lg_soundbar/__init__.py +++ b/homeassistant/components/lg_soundbar/__init__.py @@ -1 +1,38 @@ """The lg_soundbar component.""" +import logging + +from homeassistant import config_entries, core +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.exceptions import ConfigEntryNotReady + +from .config_flow import test_connect +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up platform from a ConfigEntry.""" + hass.data.setdefault(DOMAIN, {}) + # Verify the device is reachable with the given config before setting up the platform + try: + await hass.async_add_executor_job( + test_connect, entry.data[CONF_HOST], entry.data[CONF_PORT] + ) + except ConnectionError as err: + raise ConfigEntryNotReady from err + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + result = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return result diff --git a/homeassistant/components/lg_soundbar/config_flow.py b/homeassistant/components/lg_soundbar/config_flow.py new file mode 100644 index 00000000000..bd9a727d1f4 --- /dev/null +++ b/homeassistant/components/lg_soundbar/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow to configure the LG Soundbar integration.""" +from queue import Queue +import socket + +import temescal +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DEFAULT_PORT, DOMAIN + +DATA_SCHEMA = { + vol.Required(CONF_HOST): str, +} + + +def test_connect(host, port): + """LG Soundbar config flow test_connect.""" + uuid_q = Queue(maxsize=1) + name_q = Queue(maxsize=1) + + def msg_callback(response): + if response["msg"] == "MAC_INFO_DEV" and "s_uuid" in response["data"]: + uuid_q.put_nowait(response["data"]["s_uuid"]) + if ( + response["msg"] == "SPK_LIST_VIEW_INFO" + and "s_user_name" in response["data"] + ): + name_q.put_nowait(response["data"]["s_user_name"]) + + try: + connection = temescal.temescal(host, port=port, callback=msg_callback) + connection.get_mac_info() + connection.get_info() + details = {"name": name_q.get(timeout=10), "uuid": uuid_q.get(timeout=10)} + return details + except socket.timeout as err: + raise ConnectionError(f"Connection timeout with server: {host}:{port}") from err + except OSError as err: + raise ConnectionError(f"Cannot resolve hostname: {host}") from err + + +class LGSoundbarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """LG Soundbar config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_form() + + errors = {} + try: + details = await self.hass.async_add_executor_job( + test_connect, user_input[CONF_HOST], DEFAULT_PORT + ) + except ConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(details["uuid"]) + self._abort_if_unique_id_configured() + info = { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: DEFAULT_PORT, + } + return self.async_create_entry(title=details["name"], data=info) + + return self._show_form(errors) + + def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(DATA_SCHEMA), + errors=errors if errors else {}, + ) diff --git a/homeassistant/components/lg_soundbar/const.py b/homeassistant/components/lg_soundbar/const.py new file mode 100644 index 00000000000..c71e43c0d60 --- /dev/null +++ b/homeassistant/components/lg_soundbar/const.py @@ -0,0 +1,4 @@ +"""Constants for the LG Soundbar integration.""" +DOMAIN = "lg_soundbar" + +DEFAULT_PORT = 9741 diff --git a/homeassistant/components/lg_soundbar/manifest.json b/homeassistant/components/lg_soundbar/manifest.json index f40ad1d194c..c05174a8938 100644 --- a/homeassistant/components/lg_soundbar/manifest.json +++ b/homeassistant/components/lg_soundbar/manifest.json @@ -1,8 +1,9 @@ { "domain": "lg_soundbar", + "config_flow": true, "name": "LG Soundbars", "documentation": "https://www.home-assistant.io/integrations/lg_soundbar", - "requirements": ["temescal==0.3"], + "requirements": ["temescal==0.5"], "codeowners": [], "iot_class": "local_polling", "loggers": ["temescal"] diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index 569678c8c15..f8f6fcf26fd 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -7,26 +7,33 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, ) -from homeassistant.const import STATE_ON +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the LG platform.""" - if discovery_info is not None: - add_entities([LGDevice(discovery_info)]) + """Set up media_player from a config entry created in the integrations UI.""" + async_add_entities( + [ + LGDevice( + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.unique_id, + ) + ] + ) class LGDevice(MediaPlayerEntity): """Representation of an LG soundbar device.""" + _attr_should_poll = False _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE @@ -34,13 +41,13 @@ class LGDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - def __init__(self, discovery_info): + def __init__(self, host, port, unique_id): """Initialize the LG speakers.""" - self._host = discovery_info["host"] - self._port = discovery_info["port"] - self._hostname = discovery_info["hostname"] + self._host = host + self._port = port + self._attr_unique_id = unique_id - self._name = self._hostname.split(".")[0] + self._name = None self._volume = 0 self._volume_min = 0 self._volume_max = 0 @@ -68,6 +75,8 @@ class LGDevice(MediaPlayerEntity): self._device = temescal.temescal( self._host, port=self._port, callback=self.handle_event ) + self._device.get_product_info() + self._device.get_mac_info() self.update() def handle_event(self, response): @@ -116,7 +125,8 @@ class LGDevice(MediaPlayerEntity): if "i_curr_eq" in data: self._equaliser = data["i_curr_eq"] if "s_user_name" in data: - self._name = data["s_user_name"] + self._attr_name = data["s_user_name"] + self.schedule_update_ha_state() def update(self): @@ -125,17 +135,6 @@ class LGDevice(MediaPlayerEntity): self._device.get_info() self._device.get_func() self._device.get_settings() - self._device.get_product_info() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name @property def volume_level(self): diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json new file mode 100644 index 00000000000..ef7bf32a051 --- /dev/null +++ b/homeassistant/components/lg_soundbar/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "existing_instance_updated": "Updated existing configuration.", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/lg_soundbar/translations/en.json b/homeassistant/components/lg_soundbar/translations/en.json new file mode 100644 index 00000000000..a646279203f --- /dev/null +++ b/homeassistant/components/lg_soundbar/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "existing_instance_updated": "Updated existing configuration." + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 89e7ee680a5..4527f6ac298 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -1,11 +1,14 @@ """Life360 integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + import voluptuous as vol -from homeassistant import config_entries from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL -from homeassistant.components.device_tracker.const import ( - SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_EXCLUDE, @@ -16,12 +19,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_AUTHORIZATION, CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, @@ -30,182 +31,153 @@ from .const import ( CONF_MEMBERS, CONF_SHOW_AS_STATE, CONF_WARNING_THRESHOLD, + DEFAULT_OPTIONS, DOMAIN, + LOGGER, SHOW_DRIVING, SHOW_MOVING, ) -from .helpers import get_api +from .coordinator import Life360DataUpdateCoordinator -DEFAULT_PREFIX = DOMAIN +PLATFORMS = [Platform.DEVICE_TRACKER] CONF_ACCOUNTS = "accounts" SHOW_AS_STATE_OPTS = [SHOW_DRIVING, SHOW_MOVING] -def _excl_incl_list_to_filter_dict(value): - return { - "include": CONF_INCLUDE in value, - "list": value.get(CONF_EXCLUDE) or value.get(CONF_INCLUDE), - } - - -def _prefix(value): - if not value: - return "" - if not value.endswith("_"): - return f"{value}_" - return value - - -def _thresholds(config): - error_threshold = config.get(CONF_ERROR_THRESHOLD) - warning_threshold = config.get(CONF_WARNING_THRESHOLD) - if error_threshold and warning_threshold: - if error_threshold <= warning_threshold: - raise vol.Invalid( - f"{CONF_ERROR_THRESHOLD} must be larger than {CONF_WARNING_THRESHOLD}" +def _show_as_state(config: dict) -> dict: + if opts := config.pop(CONF_SHOW_AS_STATE): + if SHOW_DRIVING in opts: + config[SHOW_DRIVING] = True + if SHOW_MOVING in opts: + LOGGER.warning( + "%s is no longer supported as an option for %s", + SHOW_MOVING, + CONF_SHOW_AS_STATE, ) - elif not error_threshold and warning_threshold: - config[CONF_ERROR_THRESHOLD] = warning_threshold + 1 - elif error_threshold and not warning_threshold: - # Make them the same which effectively prevents warnings. - config[CONF_WARNING_THRESHOLD] = error_threshold - else: - # Log all errors as errors. - config[CONF_ERROR_THRESHOLD] = 1 - config[CONF_WARNING_THRESHOLD] = 1 return config -ACCOUNT_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) +def _unsupported(unsupported: set[str]) -> Callable[[dict], dict]: + """Warn about unsupported options and remove from config.""" -_SLUG_LIST = vol.All( - cv.ensure_list, [cv.slugify], vol.Length(min=1, msg="List cannot be empty") -) + def validator(config: dict) -> dict: + if unsupported_keys := unsupported & set(config): + LOGGER.warning( + "The following options are no longer supported: %s", + ", ".join(sorted(unsupported_keys)), + ) + return {k: v for k, v in config.items() if k not in unsupported} -_LOWER_STRING_LIST = vol.All( - cv.ensure_list, - [vol.All(cv.string, vol.Lower)], - vol.Length(min=1, msg="List cannot be empty"), -) + return validator -_EXCL_INCL_SLUG_LIST = vol.All( - vol.Schema( - { - vol.Exclusive(CONF_EXCLUDE, "incl_excl"): _SLUG_LIST, - vol.Exclusive(CONF_INCLUDE, "incl_excl"): _SLUG_LIST, - } - ), - cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), - _excl_incl_list_to_filter_dict, -) - -_EXCL_INCL_LOWER_STRING_LIST = vol.All( - vol.Schema( - { - vol.Exclusive(CONF_EXCLUDE, "incl_excl"): _LOWER_STRING_LIST, - vol.Exclusive(CONF_INCLUDE, "incl_excl"): _LOWER_STRING_LIST, - } - ), - cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), - _excl_incl_list_to_filter_dict, -) - -_THRESHOLD = vol.All(vol.Coerce(int), vol.Range(min=1)) +ACCOUNT_SCHEMA = { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +} +CIRCLES_MEMBERS = { + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), +} LIFE360_SCHEMA = vol.All( vol.Schema( { - vol.Optional(CONF_ACCOUNTS): vol.All( - cv.ensure_list, [ACCOUNT_SCHEMA], vol.Length(min=1) - ), - vol.Optional(CONF_CIRCLES): _EXCL_INCL_LOWER_STRING_LIST, + vol.Optional(CONF_ACCOUNTS): vol.All(cv.ensure_list, [ACCOUNT_SCHEMA]), + vol.Optional(CONF_CIRCLES): CIRCLES_MEMBERS, vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), - vol.Optional(CONF_ERROR_THRESHOLD): _THRESHOLD, + vol.Optional(CONF_ERROR_THRESHOLD): vol.Coerce(int), vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_MAX_UPDATE_WAIT): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_MEMBERS): _EXCL_INCL_SLUG_LIST, - vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): vol.All( - vol.Any(None, cv.string), _prefix - ), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, + vol.Optional(CONF_MAX_UPDATE_WAIT): cv.time_period, + vol.Optional(CONF_MEMBERS): CIRCLES_MEMBERS, + vol.Optional(CONF_PREFIX): vol.Any(None, cv.string), + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_SHOW_AS_STATE, default=[]): vol.All( cv.ensure_list, [vol.In(SHOW_AS_STATE_OPTS)] ), - vol.Optional(CONF_WARNING_THRESHOLD): _THRESHOLD, + vol.Optional(CONF_WARNING_THRESHOLD): vol.Coerce(int), } ), - _thresholds, + _unsupported( + { + CONF_ACCOUNTS, + CONF_CIRCLES, + CONF_ERROR_THRESHOLD, + CONF_MAX_UPDATE_WAIT, + CONF_MEMBERS, + CONF_PREFIX, + CONF_SCAN_INTERVAL, + CONF_WARNING_THRESHOLD, + } + ), + _show_as_state, +) +CONFIG_SCHEMA = vol.Schema( + vol.All({DOMAIN: LIFE360_SCHEMA}, cv.removed(DOMAIN, raise_if_present=False)), + extra=vol.ALLOW_EXTRA, ) -CONFIG_SCHEMA = vol.Schema({DOMAIN: LIFE360_SCHEMA}, extra=vol.ALLOW_EXTRA) + +@dataclass +class IntegData: + """Integration data.""" + + cfg_options: dict[str, Any] | None = None + # ConfigEntry.entry_id: Life360DataUpdateCoordinator + coordinators: dict[str, Life360DataUpdateCoordinator] = field( + init=False, default_factory=dict + ) + # member_id: ConfigEntry.entry_id + tracked_members: dict[str, str] = field(init=False, default_factory=dict) + logged_circles: list[str] = field(init=False, default_factory=list) + logged_places: list[str] = field(init=False, default_factory=list) + + def __post_init__(self): + """Finish initialization of cfg_options.""" + self.cfg_options = self.cfg_options or {} -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up integration.""" - conf = config.get(DOMAIN, LIFE360_SCHEMA({})) - hass.data[DOMAIN] = {"config": conf, "apis": {}} - discovery.load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, None, config) - - if CONF_ACCOUNTS not in conf: - return True - - # Check existing config entries. For any that correspond to an entry in - # configuration.yaml, and whose password has not changed, nothing needs to - # be done with that config entry or that account from configuration.yaml. - # But if the config entry was created by import and the account no longer - # exists in configuration.yaml, or if the password has changed, then delete - # that out-of-date config entry. - already_configured = [] - for entry in hass.config_entries.async_entries(DOMAIN): - # Find corresponding configuration.yaml entry and its password. - password = None - for account in conf[CONF_ACCOUNTS]: - if account[CONF_USERNAME] == entry.data[CONF_USERNAME]: - password = account[CONF_PASSWORD] - if password == entry.data[CONF_PASSWORD]: - already_configured.append(entry.data[CONF_USERNAME]) - continue - if ( - not password - and entry.source == config_entries.SOURCE_IMPORT - or password - and password != entry.data[CONF_PASSWORD] - ): - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - - # Create config entries for accounts listed in configuration. - for account in conf[CONF_ACCOUNTS]: - if account[CONF_USERNAME] not in already_configured: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=account, - ) - ) + hass.data.setdefault(DOMAIN, IntegData(config.get(DOMAIN))) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" - hass.data[DOMAIN]["apis"][entry.data[CONF_USERNAME]] = get_api( - entry.data[CONF_AUTHORIZATION] - ) + hass.data.setdefault(DOMAIN, IntegData()) + + # Check if this entry was created when this was a "legacy" tracker. If it was, + # update with missing data. + if not entry.unique_id: + hass.config_entries.async_update_entry( + entry, + unique_id=entry.data[CONF_USERNAME].lower(), + options=DEFAULT_OPTIONS | hass.data[DOMAIN].cfg_options, + ) + + coordinator = Life360DataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN].coordinators[entry.entry_id] = coordinator + + # Set up components for our platforms. + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" - try: - hass.data[DOMAIN]["apis"].pop(entry.data[CONF_USERNAME]) - return True - except KeyError: - return False + + # Unload components for our platforms. + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN].coordinators[entry.entry_id] + # Remove any members that were tracked by this entry. + for member_id, entry_id in hass.data[DOMAIN].tracked_members.copy().items(): + if entry_id == entry.entry_id: + del hass.data[DOMAIN].tracked_members[member_id] + + return unload_ok diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py index 0a200e72097..331882aa991 100644 --- a/homeassistant/components/life360/config_flow.py +++ b/homeassistant/components/life360/config_flow.py @@ -1,108 +1,199 @@ """Config flow to configure Life360 integration.""" -from collections import OrderedDict -import logging -from life360 import Life360Error, LoginError +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from life360 import Life360, Life360Error, LoginError import voluptuous as vol -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv -from .const import CONF_AUTHORIZATION, DOMAIN -from .helpers import get_api +from .const import ( + COMM_MAX_RETRIES, + COMM_TIMEOUT, + CONF_AUTHORIZATION, + CONF_DRIVING_SPEED, + CONF_MAX_GPS_ACCURACY, + DEFAULT_OPTIONS, + DOMAIN, + LOGGER, + OPTIONS, + SHOW_DRIVING, +) -_LOGGER = logging.getLogger(__name__) - -DOCS_URL = "https://www.home-assistant.io/integrations/life360" +LIMIT_GPS_ACC = "limit_gps_acc" +SET_DRIVE_SPEED = "set_drive_speed" -class Life360ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +def account_schema( + def_username: str | vol.UNDEFINED = vol.UNDEFINED, + def_password: str | vol.UNDEFINED = vol.UNDEFINED, +) -> dict[vol.Marker, Any]: + """Return schema for an account with optional default values.""" + return { + vol.Required(CONF_USERNAME, default=def_username): cv.string, + vol.Required(CONF_PASSWORD, default=def_password): cv.string, + } + + +def password_schema( + def_password: str | vol.UNDEFINED = vol.UNDEFINED, +) -> dict[vol.Marker, Any]: + """Return schema for a password with optional default value.""" + return {vol.Required(CONF_PASSWORD, default=def_password): cv.string} + + +class Life360ConfigFlow(ConfigFlow, domain=DOMAIN): """Life360 integration config flow.""" VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self._api = get_api() - self._username = vol.UNDEFINED - self._password = vol.UNDEFINED + self._api = Life360(timeout=COMM_TIMEOUT, max_retries=COMM_MAX_RETRIES) + self._username: str | vol.UNDEFINED = vol.UNDEFINED + self._password: str | vol.UNDEFINED = vol.UNDEFINED + self._reauth_entry: ConfigEntry | None = None - @property - def configured_usernames(self): - """Return tuple of configured usernames.""" - entries = self._async_current_entries() - if entries: - return (entry.data[CONF_USERNAME] for entry in entries) - return () + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> Life360OptionsFlow: + """Get the options flow for this handler.""" + return Life360OptionsFlow(config_entry) - async def async_step_user(self, user_input=None): - """Handle a user initiated config flow.""" - errors = {} - - if user_input is not None: - self._username = user_input[CONF_USERNAME] - self._password = user_input[CONF_PASSWORD] - try: - # pylint: disable=no-value-for-parameter - vol.Email()(self._username) - authorization = await self.hass.async_add_executor_job( - self._api.get_authorization, self._username, self._password - ) - except vol.Invalid: - errors[CONF_USERNAME] = "invalid_username" - except LoginError: - errors["base"] = "invalid_auth" - except Life360Error as error: - _LOGGER.error( - "Unexpected error communicating with Life360 server: %s", error - ) - errors["base"] = "unknown" - else: - if self._username in self.configured_usernames: - errors["base"] = "already_configured" - else: - return self.async_create_entry( - title=self._username, - data={ - CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, - CONF_AUTHORIZATION: authorization, - }, - description_placeholders={"docs_url": DOCS_URL}, - ) - - data_schema = OrderedDict() - data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str - data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(data_schema), - errors=errors, - description_placeholders={"docs_url": DOCS_URL}, - ) - - async def async_step_import(self, user_input): - """Import a config flow from configuration.""" - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] + async def _async_verify(self, step_id: str) -> FlowResult: + """Attempt to authorize the provided credentials.""" + errors: dict[str, str] = {} try: authorization = await self.hass.async_add_executor_job( - self._api.get_authorization, username, password + self._api.get_authorization, self._username, self._password ) - except LoginError: - _LOGGER.error("Invalid credentials for %s", username) - return self.async_abort(reason="invalid_auth") - except Life360Error as error: - _LOGGER.error( - "Unexpected error communicating with Life360 server: %s", error + except LoginError as exc: + LOGGER.debug("Login error: %s", exc) + errors["base"] = "invalid_auth" + except Life360Error as exc: + LOGGER.debug("Unexpected error communicating with Life360 server: %s", exc) + errors["base"] = "cannot_connect" + if errors: + if step_id == "user": + schema = account_schema(self._username, self._password) + else: + schema = password_schema(self._password) + return self.async_show_form( + step_id=step_id, data_schema=vol.Schema(schema), errors=errors ) - return self.async_abort(reason="unknown") + + data = { + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_AUTHORIZATION: authorization, + } + + if self._reauth_entry: + LOGGER.debug("Reauthorization successful") + self.hass.config_entries.async_update_entry(self._reauth_entry, data=data) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( - title=f"{username} (from configuration)", - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_AUTHORIZATION: authorization, - }, + title=cast(str, self.unique_id), data=data, options=DEFAULT_OPTIONS ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a config flow initiated by the user.""" + if not user_input: + return self.async_show_form( + step_id="user", data_schema=vol.Schema(account_schema()) + ) + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + await self.async_set_unique_id(self._username.lower()) + self._abort_if_unique_id_configured() + + return await self._async_verify("user") + + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization.""" + self._username = data[CONF_USERNAME] + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + # Always start with current credentials since they may still be valid and a + # simple reauthorization will be successful. + return await self.async_step_reauth_confirm(dict(data)) + + async def async_step_reauth_confirm(self, user_input: dict[str, Any]) -> FlowResult: + """Handle reauthorization completion.""" + self._password = user_input[CONF_PASSWORD] + return await self._async_verify("reauth_confirm") + + +class Life360OptionsFlow(OptionsFlow): + """Life360 integration options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle account options.""" + options = self.config_entry.options + + if user_input is not None: + new_options = _extract_account_options(user_input) + return self.async_create_entry(title="", data=new_options) + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(_account_options_schema(options)) + ) + + +def _account_options_schema(options: Mapping[str, Any]) -> dict[vol.Marker, Any]: + """Create schema for account options form.""" + def_limit_gps_acc = options[CONF_MAX_GPS_ACCURACY] is not None + def_max_gps = options[CONF_MAX_GPS_ACCURACY] or vol.UNDEFINED + def_set_drive_speed = options[CONF_DRIVING_SPEED] is not None + def_speed = options[CONF_DRIVING_SPEED] or vol.UNDEFINED + def_show_driving = options[SHOW_DRIVING] + + return { + vol.Required(LIMIT_GPS_ACC, default=def_limit_gps_acc): bool, + vol.Optional(CONF_MAX_GPS_ACCURACY, default=def_max_gps): vol.Coerce(float), + vol.Required(SET_DRIVE_SPEED, default=def_set_drive_speed): bool, + vol.Optional(CONF_DRIVING_SPEED, default=def_speed): vol.Coerce(float), + vol.Optional(SHOW_DRIVING, default=def_show_driving): bool, + } + + +def _extract_account_options(user_input: dict) -> dict[str, Any]: + """Remove options from user input and return as a separate dict.""" + result = {} + + for key in OPTIONS: + value = user_input.pop(key, None) + # Was "include" checkbox (if there was one) corresponding to option key True + # (meaning option should be included)? + incl = user_input.pop( + { + CONF_MAX_GPS_ACCURACY: LIMIT_GPS_ACC, + CONF_DRIVING_SPEED: SET_DRIVE_SPEED, + }.get(key), + True, + ) + result[key] = value if incl else None + + return result diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py index 41f4a990e67..ccaf69877d6 100644 --- a/homeassistant/components/life360/const.py +++ b/homeassistant/components/life360/const.py @@ -1,5 +1,25 @@ """Constants for Life360 integration.""" + +from datetime import timedelta +import logging + DOMAIN = "life360" +LOGGER = logging.getLogger(__package__) + +ATTRIBUTION = "Data provided by life360.com" +COMM_MAX_RETRIES = 2 +COMM_TIMEOUT = 3.05 +SPEED_FACTOR_MPH = 2.25 +SPEED_DIGITS = 1 +UPDATE_INTERVAL = timedelta(seconds=10) + +ATTR_ADDRESS = "address" +ATTR_AT_LOC_SINCE = "at_loc_since" +ATTR_DRIVING = "driving" +ATTR_LAST_SEEN = "last_seen" +ATTR_PLACE = "place" +ATTR_SPEED = "speed" +ATTR_WIFI_ON = "wifi_on" CONF_AUTHORIZATION = "authorization" CONF_CIRCLES = "circles" @@ -13,3 +33,10 @@ CONF_WARNING_THRESHOLD = "warning_threshold" SHOW_DRIVING = "driving" SHOW_MOVING = "moving" + +DEFAULT_OPTIONS = { + CONF_DRIVING_SPEED: None, + CONF_MAX_GPS_ACCURACY: None, + SHOW_DRIVING: False, +} +OPTIONS = list(DEFAULT_OPTIONS.keys()) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py new file mode 100644 index 00000000000..dc7fdb73a8c --- /dev/null +++ b/homeassistant/components/life360/coordinator.py @@ -0,0 +1,201 @@ +"""DataUpdateCoordinator for the Life360 integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from life360 import Life360, Life360Error, LoginError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + LENGTH_FEET, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.distance import convert +import homeassistant.util.dt as dt_util + +from .const import ( + COMM_MAX_RETRIES, + COMM_TIMEOUT, + CONF_AUTHORIZATION, + DOMAIN, + LOGGER, + SPEED_DIGITS, + SPEED_FACTOR_MPH, + UPDATE_INTERVAL, +) + + +@dataclass +class Life360Place: + """Life360 Place data.""" + + name: str + latitude: float + longitude: float + radius: float + + +@dataclass +class Life360Circle: + """Life360 Circle data.""" + + name: str + places: dict[str, Life360Place] + + +@dataclass +class Life360Member: + """Life360 Member data.""" + + # Don't include address field in eq comparison because it often changes (back and + # forth) between updates. If it was included there would be way more state changes + # and database updates than is useful. + address: str | None = field(compare=False) + at_loc_since: datetime + battery_charging: bool + battery_level: int + driving: bool + entity_picture: str + gps_accuracy: int + last_seen: datetime + latitude: float + longitude: float + name: str + place: str | None + speed: float + wifi_on: bool + + +@dataclass +class Life360Data: + """Life360 data.""" + + circles: dict[str, Life360Circle] = field(init=False, default_factory=dict) + members: dict[str, Life360Member] = field(init=False, default_factory=dict) + + +class Life360DataUpdateCoordinator(DataUpdateCoordinator): + """Life360 data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize data update coordinator.""" + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN} ({entry.unique_id})", + update_interval=UPDATE_INTERVAL, + ) + self._hass = hass + self._api = Life360( + timeout=COMM_TIMEOUT, + max_retries=COMM_MAX_RETRIES, + authorization=entry.data[CONF_AUTHORIZATION], + ) + + async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]: + """Get data from Life360.""" + try: + return await self._hass.async_add_executor_job( + getattr(self._api, func), *args + ) + except LoginError as exc: + LOGGER.debug("Login error: %s", exc) + raise ConfigEntryAuthFailed from exc + except Life360Error as exc: + LOGGER.debug("%s: %s", exc.__class__.__name__, exc) + raise UpdateFailed from exc + + async def _async_update_data(self) -> Life360Data: + """Get & process data from Life360.""" + + data = Life360Data() + + for circle in await self._retrieve_data("get_circles"): + circle_id = circle["id"] + circle_members = await self._retrieve_data("get_circle_members", circle_id) + circle_places = await self._retrieve_data("get_circle_places", circle_id) + + data.circles[circle_id] = Life360Circle( + circle["name"], + { + place["id"]: Life360Place( + place["name"], + float(place["latitude"]), + float(place["longitude"]), + float(place["radius"]), + ) + for place in circle_places + }, + ) + + for member in circle_members: + # Member isn't sharing location. + if not int(member["features"]["shareLocation"]): + continue + + # Note that member may be in more than one circle. If that's the case just + # go ahead and process the newly retrieved data (overwriting the older + # data), since it might be slightly newer than what was retrieved while + # processing another circle. + + first = member["firstName"] + last = member["lastName"] + if first and last: + name = " ".join([first, last]) + else: + name = first or last + + loc = member["location"] + if not loc: + if err_msg := member["issues"]["title"]: + if member["issues"]["dialog"]: + err_msg += f": {member['issues']['dialog']}" + else: + err_msg = "Location information missing" + LOGGER.error("%s: %s", name, err_msg) + continue + + place = loc["name"] or None + + if place: + address: str | None = place + else: + address1 = loc["address1"] or None + address2 = loc["address2"] or None + if address1 and address2: + address = ", ".join([address1, address2]) + else: + address = address1 or address2 + + speed = max(0, float(loc["speed"]) * SPEED_FACTOR_MPH) + if self._hass.config.units.is_metric: + speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS) + + data.members[member["id"]] = Life360Member( + address, + dt_util.utc_from_timestamp(int(loc["since"])), + bool(int(loc["charge"])), + int(float(loc["battery"])), + bool(int(loc["isDriving"])), + member["avatar"], + # Life360 reports accuracy in feet, but Device Tracker expects + # gps_accuracy in meters. + round(convert(float(loc["accuracy"]), LENGTH_FEET, LENGTH_METERS)), + dt_util.utc_from_timestamp(int(loc["timestamp"])), + float(loc["latitude"]), + float(loc["longitude"]), + name, + place, + round(speed, SPEED_DIGITS), + bool(int(loc["wifiState"])), + ) + + return data diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 2451a237a1e..5a18422487e 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -1,432 +1,244 @@ """Support for Life360 device tracking.""" + from __future__ import annotations -from collections.abc import Callable -from datetime import timedelta -import logging +from collections.abc import Mapping +from typing import Any, cast -from life360 import Life360Error -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - CONF_SCAN_INTERVAL, - DOMAIN as DEVICE_TRACKER_DOMAIN, +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_BATTERY_CHARGING +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) -from homeassistant.components.zone import async_active_zone -from homeassistant.const import ( - ATTR_BATTERY_CHARGING, - ATTR_ENTITY_ID, - CONF_PREFIX, - LENGTH_FEET, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.distance import convert -import homeassistant.util.dt as dt_util from .const import ( - CONF_CIRCLES, + ATTR_ADDRESS, + ATTR_AT_LOC_SINCE, + ATTR_DRIVING, + ATTR_LAST_SEEN, + ATTR_PLACE, + ATTR_SPEED, + ATTR_WIFI_ON, + ATTRIBUTION, CONF_DRIVING_SPEED, - CONF_ERROR_THRESHOLD, CONF_MAX_GPS_ACCURACY, - CONF_MAX_UPDATE_WAIT, - CONF_MEMBERS, - CONF_SHOW_AS_STATE, - CONF_WARNING_THRESHOLD, DOMAIN, + LOGGER, SHOW_DRIVING, - SHOW_MOVING, ) -_LOGGER = logging.getLogger(__name__) - -SPEED_FACTOR_MPH = 2.25 -EVENT_DELAY = timedelta(seconds=30) - -ATTR_ADDRESS = "address" -ATTR_AT_LOC_SINCE = "at_loc_since" -ATTR_DRIVING = "driving" -ATTR_LAST_SEEN = "last_seen" -ATTR_MOVING = "moving" -ATTR_PLACE = "place" -ATTR_RAW_SPEED = "raw_speed" -ATTR_SPEED = "speed" -ATTR_WAIT = "wait" -ATTR_WIFI_ON = "wifi_on" - -EVENT_UPDATE_OVERDUE = "life360_update_overdue" -EVENT_UPDATE_RESTORED = "life360_update_restored" +_LOC_ATTRS = ( + "address", + "at_loc_since", + "driving", + "gps_accuracy", + "last_seen", + "latitude", + "longitude", + "place", + "speed", +) -def _include_name(filter_dict, name): - if not name: +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the device tracker platform.""" + coordinator = hass.data[DOMAIN].coordinators[entry.entry_id] + tracked_members = hass.data[DOMAIN].tracked_members + logged_circles = hass.data[DOMAIN].logged_circles + logged_places = hass.data[DOMAIN].logged_places + + @callback + def process_data(new_members_only: bool = True) -> None: + """Process new Life360 data.""" + for circle_id, circle in coordinator.data.circles.items(): + if circle_id not in logged_circles: + logged_circles.append(circle_id) + LOGGER.debug("Circle: %s", circle.name) + + new_places = [] + for place_id, place in circle.places.items(): + if place_id not in logged_places: + logged_places.append(place_id) + new_places.append(place) + if new_places: + msg = f"Places from {circle.name}:" + for place in new_places: + msg += f"\n- name: {place.name}" + msg += f"\n latitude: {place.latitude}" + msg += f"\n longitude: {place.longitude}" + msg += f"\n radius: {place.radius}" + LOGGER.debug(msg) + + new_entities = [] + for member_id, member in coordinator.data.members.items(): + tracked_by_entry = tracked_members.get(member_id) + if new_member := not tracked_by_entry: + tracked_members[member_id] = entry.entry_id + LOGGER.debug("Member: %s (%s)", member.name, entry.unique_id) + if ( + new_member + or tracked_by_entry == entry.entry_id + and not new_members_only + ): + new_entities.append(Life360DeviceTracker(coordinator, member_id)) + if new_entities: + async_add_entities(new_entities) + + process_data(new_members_only=False) + entry.async_on_unload(coordinator.async_add_listener(process_data)) + + +class Life360DeviceTracker(CoordinatorEntity, TrackerEntity): + """Life360 Device Tracker.""" + + _attr_attribution = ATTRIBUTION + + def __init__(self, coordinator: DataUpdateCoordinator, member_id: str) -> None: + """Initialize Life360 Entity.""" + super().__init__(coordinator) + self._attr_unique_id = member_id + + self._data = coordinator.data.members[self.unique_id] + + self._attr_name = self._data.name + self._attr_entity_picture = self._data.entity_picture + + self._prev_data = self._data + + @property + def _options(self) -> Mapping[str, Any]: + """Shortcut to config entry options.""" + return cast(Mapping[str, Any], self.coordinator.config_entry.options) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # Get a shortcut to this member's data. Can't guarantee it's the same dict every + # update, or that there is even data for this member every update, so need to + # update shortcut each time. + self._data = self.coordinator.data.members.get(self.unique_id) + + if self.available: + # If nothing important has changed, then skip the update altogether. + if self._data == self._prev_data: + return + + # Check if we should effectively throw out new location data. + last_seen = self._data.last_seen + prev_seen = self._prev_data.last_seen + max_gps_acc = self._options.get(CONF_MAX_GPS_ACCURACY) + bad_last_seen = last_seen < prev_seen + bad_accuracy = ( + max_gps_acc is not None and self.location_accuracy > max_gps_acc + ) + if bad_last_seen or bad_accuracy: + if bad_last_seen: + LOGGER.warning( + "%s: Ignoring location update because " + "last_seen (%s) < previous last_seen (%s)", + self.entity_id, + last_seen, + prev_seen, + ) + if bad_accuracy: + LOGGER.warning( + "%s: Ignoring location update because " + "expected GPS accuracy (%0.1f) is not met: %i", + self.entity_id, + max_gps_acc, + self.location_accuracy, + ) + # Overwrite new location related data with previous values. + for attr in _LOC_ATTRS: + setattr(self._data, attr, getattr(self._prev_data, attr)) + + self._prev_data = self._data + + super()._handle_coordinator_update() + + @property + def force_update(self) -> bool: + """Return True if state updates should be forced.""" return False - if not filter_dict: - return True - name = name.lower() - if filter_dict["include"]: - return name in filter_dict["list"] - return name not in filter_dict["list"] + @property + def available(self) -> bool: + """Return if entity is available.""" + # Guard against member not being in last update for some reason. + return super().available and self._data is not None -def _exc_msg(exc): - return f"{exc.__class__.__name__}: {exc}" + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + if self.available: + self._attr_entity_picture = self._data.entity_picture + return super().entity_picture + # All of the following will only be called if self.available is True. -def _dump_filter(filter_dict, desc, func=lambda x: x): - if not filter_dict: - return - _LOGGER.debug( - "%scluding %s: %s", - "In" if filter_dict["include"] else "Ex", - desc, - ", ".join([func(name) for name in filter_dict["list"]]), - ) + @property + def battery_level(self) -> int | None: + """Return the battery level of the device. + Percentage from 0-100. + """ + return self._data.battery_level -def setup_scanner( - hass: HomeAssistant, - config: ConfigType, - see: Callable[..., None], - discovery_info: DiscoveryInfoType | None = None, -) -> bool: - """Set up device scanner.""" - config = hass.data[DOMAIN]["config"] - apis = hass.data[DOMAIN]["apis"] - Life360Scanner(hass, config, see, apis) - return True + @property + def source_type(self) -> str: + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + @property + def location_accuracy(self) -> int: + """Return the location accuracy of the device. -def _utc_from_ts(val): - try: - return dt_util.utc_from_timestamp(float(val)) - except (TypeError, ValueError): + Value in meters. + """ + return self._data.gps_accuracy + + @property + def driving(self) -> bool: + """Return if driving.""" + if (driving_speed := self._options.get(CONF_DRIVING_SPEED)) is not None: + if self._data.speed >= driving_speed: + return True + return self._data.driving + + @property + def location_name(self) -> str | None: + """Return a location name for the current location of the device.""" + if self._options.get(SHOW_DRIVING) and self.driving: + return "Driving" return None + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self._data.latitude -def _dt_attr_from_ts(timestamp): - utc = _utc_from_ts(timestamp) - if utc: - return utc - return STATE_UNKNOWN + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self._data.longitude - -def _bool_attr_from_int(val): - try: - return bool(int(val)) - except (TypeError, ValueError): - return STATE_UNKNOWN - - -class Life360Scanner: - """Life360 device scanner.""" - - def __init__(self, hass, config, see, apis): - """Initialize Life360Scanner.""" - self._hass = hass - self._see = see - self._max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - self._max_update_wait = config.get(CONF_MAX_UPDATE_WAIT) - self._prefix = config[CONF_PREFIX] - self._circles_filter = config.get(CONF_CIRCLES) - self._members_filter = config.get(CONF_MEMBERS) - self._driving_speed = config.get(CONF_DRIVING_SPEED) - self._show_as_state = config[CONF_SHOW_AS_STATE] - self._apis = apis - self._errs = {} - self._error_threshold = config[CONF_ERROR_THRESHOLD] - self._warning_threshold = config[CONF_WARNING_THRESHOLD] - self._max_errs = self._error_threshold + 1 - self._dev_data = {} - self._circles_logged = set() - self._members_logged = set() - - _dump_filter(self._circles_filter, "Circles") - _dump_filter(self._members_filter, "device IDs", self._dev_id) - - self._started = dt_util.utcnow() - self._update_life360() - track_time_interval( - self._hass, self._update_life360, config[CONF_SCAN_INTERVAL] - ) - - def _dev_id(self, name): - return self._prefix + name - - def _ok(self, key): - if self._errs.get(key, 0) >= self._max_errs: - _LOGGER.error("%s: OK again", key) - self._errs[key] = 0 - - def _err(self, key, err_msg): - _errs = self._errs.get(key, 0) - if _errs < self._max_errs: - self._errs[key] = _errs = _errs + 1 - msg = f"{key}: {err_msg}" - if _errs >= self._error_threshold: - if _errs == self._max_errs: - msg = f"Suppressing further errors until OK: {msg}" - _LOGGER.error(msg) - elif _errs >= self._warning_threshold: - _LOGGER.warning(msg) - - def _exc(self, key, exc): - self._err(key, _exc_msg(exc)) - - def _prev_seen(self, dev_id, last_seen): - prev_seen, reported = self._dev_data.get(dev_id, (None, False)) - - if self._max_update_wait: - now = dt_util.utcnow() - most_recent_update = last_seen or prev_seen or self._started - overdue = now - most_recent_update > self._max_update_wait - if overdue and not reported and now - self._started > EVENT_DELAY: - self._hass.bus.fire( - EVENT_UPDATE_OVERDUE, - {ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}"}, - ) - reported = True - elif not overdue and reported: - self._hass.bus.fire( - EVENT_UPDATE_RESTORED, - { - ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}", - ATTR_WAIT: str(last_seen - (prev_seen or self._started)).split( - ".", maxsplit=1 - )[0], - }, - ) - reported = False - - # Don't remember last_seen unless it's really an update. - if not last_seen or prev_seen and last_seen <= prev_seen: - last_seen = prev_seen - self._dev_data[dev_id] = last_seen, reported - - return prev_seen - - def _update_member(self, member, dev_id): - loc = member.get("location") - try: - last_seen = _utc_from_ts(loc.get("timestamp")) - except AttributeError: - last_seen = None - prev_seen = self._prev_seen(dev_id, last_seen) - - if not loc: - if err_msg := member["issues"]["title"]: - if member["issues"]["dialog"]: - err_msg += f": {member['issues']['dialog']}" - else: - err_msg = "Location information missing" - self._err(dev_id, err_msg) - return - - # Only update when we truly have an update. - if not last_seen: - _LOGGER.warning("%s: Ignoring update because timestamp is missing", dev_id) - return - if prev_seen and last_seen < prev_seen: - _LOGGER.warning( - "%s: Ignoring update because timestamp is older than last timestamp", - dev_id, - ) - _LOGGER.debug("%s < %s", last_seen, prev_seen) - return - if last_seen == prev_seen: - return - - lat = loc.get("latitude") - lon = loc.get("longitude") - gps_accuracy = loc.get("accuracy") - try: - lat = float(lat) - lon = float(lon) - # Life360 reports accuracy in feet, but Device Tracker expects - # gps_accuracy in meters. - gps_accuracy = round( - convert(float(gps_accuracy), LENGTH_FEET, LENGTH_METERS) - ) - except (TypeError, ValueError): - self._err(dev_id, f"GPS data invalid: {lat}, {lon}, {gps_accuracy}") - return - - self._ok(dev_id) - - msg = f"Updating {dev_id}" - if prev_seen: - msg += f"; Time since last update: {last_seen - prev_seen}" - _LOGGER.debug(msg) - - if self._max_gps_accuracy is not None and gps_accuracy > self._max_gps_accuracy: - _LOGGER.warning( - "%s: Ignoring update because expected GPS " - "accuracy (%.0f) is not met: %.0f", - dev_id, - self._max_gps_accuracy, - gps_accuracy, - ) - return - - # Get raw attribute data, converting empty strings to None. - place = loc.get("name") or None - address1 = loc.get("address1") or None - address2 = loc.get("address2") or None - if address1 and address2: - address = ", ".join([address1, address2]) - else: - address = address1 or address2 - raw_speed = loc.get("speed") or None - driving = _bool_attr_from_int(loc.get("isDriving")) - moving = _bool_attr_from_int(loc.get("inTransit")) - try: - battery = int(float(loc.get("battery"))) - except (TypeError, ValueError): - battery = None - - # Try to convert raw speed into real speed. - try: - speed = float(raw_speed) * SPEED_FACTOR_MPH - if self._hass.config.units.is_metric: - speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS) - speed = max(0, round(speed)) - except (TypeError, ValueError): - speed = STATE_UNKNOWN - - # Make driving attribute True if it isn't and we can derive that it - # should be True from other data. - if ( - driving in (STATE_UNKNOWN, False) - and self._driving_speed is not None - and speed != STATE_UNKNOWN - ): - driving = speed >= self._driving_speed - - attrs = { - ATTR_ADDRESS: address, - ATTR_AT_LOC_SINCE: _dt_attr_from_ts(loc.get("since")), - ATTR_BATTERY_CHARGING: _bool_attr_from_int(loc.get("charge")), - ATTR_DRIVING: driving, - ATTR_LAST_SEEN: last_seen, - ATTR_MOVING: moving, - ATTR_PLACE: place, - ATTR_RAW_SPEED: raw_speed, - ATTR_SPEED: speed, - ATTR_WIFI_ON: _bool_attr_from_int(loc.get("wifiState")), - } - - # If user wants driving or moving to be shown as state, and current - # location is not in a HA zone, then set location name accordingly. - loc_name = None - active_zone = run_callback_threadsafe( - self._hass.loop, async_active_zone, self._hass, lat, lon, gps_accuracy - ).result() - if not active_zone: - if SHOW_DRIVING in self._show_as_state and driving is True: - loc_name = SHOW_DRIVING - elif SHOW_MOVING in self._show_as_state and moving is True: - loc_name = SHOW_MOVING - - self._see( - dev_id=dev_id, - location_name=loc_name, - gps=(lat, lon), - gps_accuracy=gps_accuracy, - battery=battery, - attributes=attrs, - picture=member.get("avatar"), - ) - - def _update_members(self, members, members_updated): - for member in members: - member_id = member["id"] - if member_id in members_updated: - continue - err_key = "Member data" - try: - first = member.get("firstName") - last = member.get("lastName") - if first and last: - full_name = " ".join([first, last]) - else: - full_name = first or last - slug_name = cv.slugify(full_name) - include_member = _include_name(self._members_filter, slug_name) - dev_id = self._dev_id(slug_name) - if member_id not in self._members_logged: - self._members_logged.add(member_id) - _LOGGER.debug( - "%s -> %s: will%s be tracked, id=%s", - full_name, - dev_id, - "" if include_member else " NOT", - member_id, - ) - sharing = bool(int(member["features"]["shareLocation"])) - except (KeyError, TypeError, ValueError, vol.Invalid): - self._err(err_key, member) - continue - self._ok(err_key) - - if include_member and sharing: - members_updated.append(member_id) - self._update_member(member, dev_id) - - def _update_life360(self, now=None): - circles_updated = [] - members_updated = [] - - for api in self._apis.values(): - err_key = "get_circles" - try: - circles = api.get_circles() - except Life360Error as exc: - self._exc(err_key, exc) - continue - self._ok(err_key) - - for circle in circles: - circle_id = circle["id"] - if circle_id in circles_updated: - continue - circles_updated.append(circle_id) - circle_name = circle["name"] - incl_circle = _include_name(self._circles_filter, circle_name) - if circle_id not in self._circles_logged: - self._circles_logged.add(circle_id) - _LOGGER.debug( - "%s Circle: will%s be included, id=%s", - circle_name, - "" if incl_circle else " NOT", - circle_id, - ) - try: - places = api.get_circle_places(circle_id) - place_data = "Circle's Places:" - for place in places: - place_data += f"\n- name: {place['name']}" - place_data += f"\n latitude: {place['latitude']}" - place_data += f"\n longitude: {place['longitude']}" - place_data += f"\n radius: {place['radius']}" - if not places: - place_data += " None" - _LOGGER.debug(place_data) - except (Life360Error, KeyError): - pass - if incl_circle: - err_key = f'get_circle_members "{circle_name}"' - try: - members = api.get_circle_members(circle_id) - except Life360Error as exc: - self._exc(err_key, exc) - continue - self._ok(err_key) - - self._update_members(members, members_updated) + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + attrs = {} + attrs[ATTR_ADDRESS] = self._data.address + attrs[ATTR_AT_LOC_SINCE] = self._data.at_loc_since + attrs[ATTR_BATTERY_CHARGING] = self._data.battery_charging + attrs[ATTR_DRIVING] = self.driving + attrs[ATTR_LAST_SEEN] = self._data.last_seen + attrs[ATTR_PLACE] = self._data.place + attrs[ATTR_SPEED] = self._data.speed + attrs[ATTR_WIFI_ON] = self._data.wifi_on + return attrs diff --git a/homeassistant/components/life360/helpers.py b/homeassistant/components/life360/helpers.py deleted file mode 100644 index 0eb215743df..00000000000 --- a/homeassistant/components/life360/helpers.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Life360 integration helpers.""" -from life360 import Life360 - - -def get_api(authorization=None): - """Create Life360 api object.""" - return Life360(timeout=3.05, max_retries=2, authorization=authorization) diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json index 06ac88467ef..cc31ca64a08 100644 --- a/homeassistant/components/life360/strings.json +++ b/homeassistant/components/life360/strings.json @@ -2,26 +2,43 @@ "config": { "step": { "user": { - "title": "Life360 Account Info", + "title": "Configure Life360 Account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" - }, - "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { - "invalid_username": "Invalid username", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, - "create_entry": { - "default": "To set advanced options, see [Life360 documentation]({docs_url})." - }, "abort": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "options": { + "step": { + "init": { + "title": "Account Options", + "data": { + "limit_gps_acc": "Limit GPS accuracy", + "max_gps_accuracy": "Max GPS accuracy (meters)", + "set_drive_speed": "Set driving speed threshold", + "driving_speed": "Driving speed", + "driving": "Show driving as state" + } + } } } } diff --git a/homeassistant/components/life360/translations/bg.json b/homeassistant/components/life360/translations/bg.json index 5436cfcf718..d206a606b89 100644 --- a/homeassistant/components/life360/translations/bg.json +++ b/homeassistant/components/life360/translations/bg.json @@ -8,7 +8,8 @@ }, "error": { "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", - "invalid_username": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + "invalid_username": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { diff --git a/homeassistant/components/life360/translations/en.json b/homeassistant/components/life360/translations/en.json index fa836c62b61..b4c9eb452f6 100644 --- a/homeassistant/components/life360/translations/en.json +++ b/homeassistant/components/life360/translations/en.json @@ -1,27 +1,44 @@ { - "config": { - "abort": { - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "create_entry": { - "default": "To set advanced options, see [Life360 documentation]({docs_url})." - }, - "error": { - "already_configured": "Account is already configured", - "invalid_auth": "Invalid authentication", - "invalid_username": "Invalid username", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Username" - }, - "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts.", - "title": "Life360 Account Info" - } + "config": { + "step": { + "user": { + "title": "Configure Life360 Account", + "data": { + "username": "Username", + "password": "Password" } + }, + "reauth_confirm": { + "title": "Reauthenticate Integration", + "data": { + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid authentication", + "already_configured": "Account is already configured", + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "abort": { + "invalid_auth": "Invalid authentication", + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" } -} \ No newline at end of file + }, + "options": { + "step": { + "init": { + "title": "Account Options", + "data": { + "limit_gps_acc": "Limit GPS accuracy", + "max_gps_accuracy": "Max GPS accuracy (meters)", + "set_drive_speed": "Set driving speed threshold", + "driving_speed": "Driving speed", + "driving": "Show driving as state" + } + } + } + } +} diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 31e973874d9..28390e5c02a 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta from functools import partial from ipaddress import IPv4Address @@ -9,6 +10,7 @@ import logging import math import aiolifx as aiolifx_module +from aiolifx.aiolifx import LifxDiscovery, Light import aiolifx_effects as aiolifx_effects_module from awesomeversion import AwesomeVersion import voluptuous as vol @@ -49,7 +51,7 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -67,13 +69,12 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) -DISCOVERY_INTERVAL = 60 +DISCOVERY_INTERVAL = 10 MESSAGE_TIMEOUT = 1 -MESSAGE_RETRIES = 3 +MESSAGE_RETRIES = 8 UNAVAILABLE_GRACE = 90 FIX_MAC_FW = AwesomeVersion("3.70") -SWITCH_PRODUCT_IDS = [70, 71, 89] SERVICE_LIFX_SET_STATE = "set_state" @@ -253,19 +254,34 @@ def merge_hsbk(base, change): return [b if c is None else c for b, c in zip(base, change)] +@dataclass +class InFlightDiscovery: + """Represent a LIFX device that is being discovered.""" + + device: Light + lock: asyncio.Lock + + class LIFXManager: """Representation of all known LIFX entities.""" - def __init__(self, hass, platform, config_entry, async_add_entities): + def __init__( + self, + hass: HomeAssistant, + platform: EntityPlatform, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: """Initialize the light.""" - self.entities = {} - self.discoveries_inflight = {} + self.entities: dict[str, LIFXLight] = {} + self.switch_devices: list[str] = [] self.hass = hass self.platform = platform self.config_entry = config_entry self.async_add_entities = async_add_entities self.effects_conductor = aiolifx_effects().Conductor(hass.loop) - self.discoveries = [] + self.discoveries: list[LifxDiscovery] = [] + self.discoveries_inflight: dict[str, InFlightDiscovery] = {} self.cleanup_unsub = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self.cleanup ) @@ -377,72 +393,128 @@ class LIFXManager: elif service == SERVICE_EFFECT_STOP: await self.effects_conductor.stop(bulbs) - @callback - def register(self, bulb): - """Allow a single in-flight discovery per bulb.""" - if bulb.mac_addr not in self.discoveries_inflight: - self.discoveries_inflight[bulb.mac_addr] = bulb.ip_addr - _LOGGER.debug("Discovered %s (%s)", bulb.ip_addr, bulb.mac_addr) - self.hass.async_create_task(self.register_bulb(bulb)) - else: - _LOGGER.warning("Duplicate LIFX discovery response ignored") + def clear_inflight_discovery(self, inflight: InFlightDiscovery) -> None: + """Clear in-flight discovery.""" + self.discoveries_inflight.pop(inflight.device.mac_addr, None) - async def register_bulb(self, bulb): - """Handle LIFX bulb registration lifecycle.""" + @callback + def register(self, bulb: Light) -> None: + """Allow a single in-flight discovery per bulb.""" + if bulb.mac_addr in self.switch_devices: + _LOGGER.debug( + "Skipping discovered LIFX Switch at %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + return + + # Try to bail out of discovery as early as possible if bulb.mac_addr in self.entities: entity = self.entities[bulb.mac_addr] entity.registered = True _LOGGER.debug("Reconnected to %s", entity.who) - await entity.update_hass() - else: - _LOGGER.debug("Connecting to %s (%s)", bulb.ip_addr, bulb.mac_addr) + return - # Read initial state + if bulb.mac_addr not in self.discoveries_inflight: + inflight = InFlightDiscovery(bulb, asyncio.Lock()) + self.discoveries_inflight[bulb.mac_addr] = inflight + _LOGGER.debug( + "First discovery response received from %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + else: + _LOGGER.debug( + "Duplicate discovery response received from %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + + self.hass.async_create_task( + self._async_handle_discovery(self.discoveries_inflight[bulb.mac_addr]) + ) + + async def _async_handle_discovery(self, inflight: InFlightDiscovery) -> None: + """Handle LIFX bulb registration lifecycle.""" + + # only allow a single discovery process per discovered device + async with inflight.lock: + + # Bail out if an entity was created by a previous discovery while + # this discovery was waiting for the asyncio lock to release. + if inflight.device.mac_addr in self.entities: + self.clear_inflight_discovery(inflight) + entity: LIFXLight = self.entities[inflight.device.mac_addr] + entity.registered = True + _LOGGER.debug("Reconnected to %s", entity.who) + return + + # Determine the product info so that LIFX Switches + # can be skipped. ack = AwaitAioLIFX().wait - # Get the product info first so that LIFX Switches - # can be ignored. - version_resp = await ack(bulb.get_version) - if version_resp and bulb.product in SWITCH_PRODUCT_IDS: + if inflight.device.product is None: + if await ack(inflight.device.get_version) is None: + _LOGGER.debug( + "Failed to discover product information for %s (%s)", + inflight.device.ip_addr, + inflight.device.mac_addr, + ) + self.clear_inflight_discovery(inflight) + return + + if lifx_features(inflight.device)["relays"] is True: _LOGGER.debug( - "Not connecting to LIFX Switch %s (%s)", - str(bulb.mac_addr).replace(":", ""), - bulb.ip_addr, + "Skipping discovered LIFX Switch at %s (%s)", + inflight.device.ip_addr, + inflight.device.mac_addr, ) - return False + self.switch_devices.append(inflight.device.mac_addr) + self.clear_inflight_discovery(inflight) + return - color_resp = await ack(bulb.get_color) + await self._async_process_discovery(inflight=inflight) - if color_resp is None or version_resp is None: - _LOGGER.error("Failed to connect to %s", bulb.ip_addr) - bulb.registered = False - if bulb.mac_addr in self.discoveries_inflight: - self.discoveries_inflight.pop(bulb.mac_addr) - else: - bulb.timeout = MESSAGE_TIMEOUT - bulb.retry_count = MESSAGE_RETRIES - bulb.unregister_timeout = UNAVAILABLE_GRACE + async def _async_process_discovery(self, inflight: InFlightDiscovery) -> None: + """Process discovery of a device.""" + bulb = inflight.device + ack = AwaitAioLIFX().wait - if lifx_features(bulb)["multizone"]: - entity = LIFXStrip(bulb, self.effects_conductor) - elif lifx_features(bulb)["color"]: - entity = LIFXColor(bulb, self.effects_conductor) - else: - entity = LIFXWhite(bulb, self.effects_conductor) + bulb.timeout = MESSAGE_TIMEOUT + bulb.retry_count = MESSAGE_RETRIES + bulb.unregister_timeout = UNAVAILABLE_GRACE - _LOGGER.debug("Connected to %s", entity.who) - self.entities[bulb.mac_addr] = entity - self.discoveries_inflight.pop(bulb.mac_addr, None) - self.async_add_entities([entity], True) + # Read initial state + if bulb.color is None: + if await ack(bulb.get_color) is None: + _LOGGER.debug( + "Failed to determine current state of %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + self.clear_inflight_discovery(inflight) + return + + if lifx_features(bulb)["multizone"]: + entity: LIFXLight = LIFXStrip(bulb.mac_addr, bulb, self.effects_conductor) + elif lifx_features(bulb)["color"]: + entity = LIFXColor(bulb.mac_addr, bulb, self.effects_conductor) + else: + entity = LIFXWhite(bulb.mac_addr, bulb, self.effects_conductor) + + self.entities[bulb.mac_addr] = entity + self.async_add_entities([entity], True) + _LOGGER.debug("Entity created for %s", entity.who) + self.clear_inflight_discovery(inflight) @callback - def unregister(self, bulb): - """Disconnect and unregister non-responsive bulbs.""" + def unregister(self, bulb: Light) -> None: + """Mark unresponsive bulbs as unavailable in Home Assistant.""" if bulb.mac_addr in self.entities: entity = self.entities[bulb.mac_addr] - _LOGGER.debug("Disconnected from %s", entity.who) entity.registered = False entity.async_write_ha_state() + _LOGGER.debug("Disconnected from %s", entity.who) @callback def entity_registry_updated(self, event): @@ -507,8 +579,14 @@ class LIFXLight(LightEntity): _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT - def __init__(self, bulb, effects_conductor): + def __init__( + self, + mac_addr: str, + bulb: Light, + effects_conductor: aiolifx_effects_module.Conductor, + ) -> None: """Initialize the light.""" + self.mac_addr = mac_addr self.bulb = bulb self.effects_conductor = effects_conductor self.registered = True @@ -521,10 +599,10 @@ class LIFXLight(LightEntity): self.bulb.host_firmware_version and AwesomeVersion(self.bulb.host_firmware_version) >= FIX_MAC_FW ): - octets = [int(octet, 16) for octet in self.bulb.mac_addr.split(":")] + octets = [int(octet, 16) for octet in self.mac_addr.split(":")] octets[5] = (octets[5] + 1) % 256 return ":".join(f"{octet:02x}" for octet in octets) - return self.bulb.mac_addr + return self.mac_addr @property def device_info(self) -> DeviceInfo: @@ -553,7 +631,7 @@ class LIFXLight(LightEntity): @property def unique_id(self): """Return a unique ID.""" - return self.bulb.mac_addr + return self.mac_addr @property def name(self): @@ -563,7 +641,7 @@ class LIFXLight(LightEntity): @property def who(self): """Return a string identifying the bulb by name and mac.""" - return f"{self.name} ({self.bulb.mac_addr})" + return f"{self.name} ({self.mac_addr})" @property def min_mireds(self): diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index a7f266b6f7d..06e7b292ac6 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.7.1", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.8.1", "aiolifx_effects==0.2.2"], "dependencies": ["network"], "homekit": { "models": [ diff --git a/homeassistant/components/light/translations/zh-Hans.json b/homeassistant/components/light/translations/zh-Hans.json index 1054820c6bb..93f16833984 100644 --- a/homeassistant/components/light/translations/zh-Hans.json +++ b/homeassistant/components/light/translations/zh-Hans.json @@ -13,6 +13,7 @@ "is_on": "{entity_name} \u5df2\u6253\u5f00" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" } diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index df20337a816..e14eda1b745 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -19,7 +19,7 @@ from .const import CONF_DEFAULT_TRANSITION, DOMAIN class LiteJetOptionsFlow(config_entries.OptionsFlow): """Handle LiteJet options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize LiteJet options flow.""" self.config_entry = config_entry @@ -85,6 +85,8 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> LiteJetOptionsFlow: """Get the options flow for this handler.""" return LiteJetOptionsFlow(config_entry) diff --git a/homeassistant/components/litejet/translations/bg.json b/homeassistant/components/litejet/translations/bg.json index 8c1b2bfb218..c4ccfa52041 100644 --- a/homeassistant/components/litejet/translations/bg.json +++ b/homeassistant/components/litejet/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/litterrobot/translations/bg.json b/homeassistant/components/litterrobot/translations/bg.json index 67a484573aa..bad1fba5a87 100644 --- a/homeassistant/components/litterrobot/translations/bg.json +++ b/homeassistant/components/litterrobot/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/litterrobot/translations/sensor.es.json b/homeassistant/components/litterrobot/translations/sensor.es.json index 1bf023d68bc..ca5695d1347 100644 --- a/homeassistant/components/litterrobot/translations/sensor.es.json +++ b/homeassistant/components/litterrobot/translations/sensor.es.json @@ -4,11 +4,25 @@ "br": "Bolsa extra\u00edda", "ccc": "Ciclo de limpieza completado", "ccp": "Ciclo de limpieza en curso", + "csf": "Fallo del sensor de gatos", + "csi": "Sensor de gatos interrumpido", + "cst": "Tiempo del sensor de gatos", + "df1": "Caj\u00f3n casi lleno - Quedan 2 ciclos", + "df2": "Caj\u00f3n casi lleno - Queda 1 ciclo", + "dfs": "Caj\u00f3n lleno", "dhf": "Error de posici\u00f3n de vertido + inicio", + "dpf": "Fallo de posici\u00f3n de descarga", "ec": "Ciclo vac\u00edo", + "hpf": "Fallo de posici\u00f3n inicial", "off": "Apagado", "offline": "Desconectado", - "rdy": "Listo" + "otf": "Fallo de par excesivo", + "p": "Pausada", + "pd": "Detecci\u00f3n de pellizcos", + "rdy": "Listo", + "scf": "Fallo del sensor de gatos al inicio", + "sdf": "Caj\u00f3n lleno al inicio", + "spf": "Detecci\u00f3n de pellizco al inicio" } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/sensor.he.json b/homeassistant/components/litterrobot/translations/sensor.he.json index 0c693d10157..73f622d7a1a 100644 --- a/homeassistant/components/litterrobot/translations/sensor.he.json +++ b/homeassistant/components/litterrobot/translations/sensor.he.json @@ -2,7 +2,8 @@ "state": { "litterrobot__status_code": { "off": "\u05db\u05d1\u05d5\u05d9", - "p": "\u05de\u05d5\u05e9\u05d4\u05d4" + "p": "\u05de\u05d5\u05e9\u05d4\u05d4", + "rdy": "\u05de\u05d5\u05db\u05df" } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/sv.json b/homeassistant/components/litterrobot/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/litterrobot/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index dbe51270857..51be573b14e 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -1,7 +1,6 @@ """Support for Litter-Robot "Vacuum".""" from __future__ import annotations -from datetime import datetime, timedelta, timezone import logging from typing import Any @@ -46,7 +45,6 @@ LITTER_BOX_STATUS_STATE_MAP = { LitterBoxStatus.CAT_SENSOR_INTERRUPTED: STATE_PAUSED, LitterBoxStatus.OFF: STATE_OFF, } -UNAVAILABLE_AFTER = timedelta(minutes=30) async def async_setup_entry( @@ -96,11 +94,6 @@ class LitterRobotCleaner(LitterRobotControlEntity, StateVacuumEntity): | VacuumEntityFeature.TURN_ON ) - @property - def available(self) -> bool: - """Return True if the cleaner has been seen recently.""" - return self.robot.last_seen > datetime.now(timezone.utc) - UNAVAILABLE_AFTER - @property def state(self) -> str: """Return the state of the cleaner.""" diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 82225df8364..6d491ec2892 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -377,9 +377,9 @@ def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool: """Check of rows match by using the same method as Events __hash__.""" if ( row is other_row - or (state_id := row.state_id) is not None + or (state_id := row.state_id) and state_id == other_row.state_id - or (event_id := row.event_id) is not None + or (event_id := row.event_id) and event_id == other_row.event_id ): return True diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index a59ebc94b87..0c3a63f990e 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -2,11 +2,11 @@ from __future__ import annotations from datetime import datetime as dt -import json -from sqlalchemy.sql.selectable import Select +from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters +from homeassistant.helpers.json import json_dumps from .all import all_stmt from .devices import devices_stmt @@ -22,7 +22,7 @@ def statement_for_request( device_ids: list[str] | None = None, filters: Filters | None = None, context_id: str | None = None, -) -> Select: +) -> StatementLambdaElement: """Generate the logbook statement for a logbook request.""" # No entities: logbook sends everything for the timeframe @@ -39,10 +39,15 @@ def statement_for_request( context_id, ) + # sqlalchemy caches object quoting, the + # json quotable ones must be a different + # object from the non-json ones to prevent + # sqlalchemy from quoting them incorrectly + # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quoted_entity_ids = [json.dumps(entity_id) for entity_id in entity_ids] - json_quoted_device_ids = [json.dumps(device_id) for device_id in device_ids] + json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] + json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return entities_devices_stmt( start_day, end_day, @@ -54,7 +59,7 @@ def statement_for_request( # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quoted_entity_ids = [json.dumps(entity_id) for entity_id in entity_ids] + json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] return entities_stmt( start_day, end_day, @@ -65,7 +70,7 @@ def statement_for_request( # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quoted_device_ids = [json.dumps(device_id) for device_id in device_ids] + json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return devices_stmt( start_day, end_day, diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 730b66eef52..da05aa02fff 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -3,11 +3,16 @@ from __future__ import annotations from datetime import datetime as dt +from sqlalchemy import lambda_stmt from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList -from sqlalchemy.sql.selectable import Select +from sqlalchemy.sql.lambdas import StatementLambdaElement -from homeassistant.components.recorder.models import LAST_UPDATED_INDEX, Events, States +from homeassistant.components.recorder.db_schema import ( + LAST_UPDATED_INDEX, + Events, + States, +) from .common import ( apply_states_filters, @@ -24,29 +29,32 @@ def all_stmt( states_entity_filter: ClauseList | None = None, events_entity_filter: ClauseList | None = None, context_id: str | None = None, -) -> Select: +) -> StatementLambdaElement: """Generate a logbook query for all entities.""" - stmt = select_events_without_states(start_day, end_day, event_types) + stmt = lambda_stmt( + lambda: select_events_without_states(start_day, end_day, event_types) + ) if context_id is not None: # Once all the old `state_changed` events # are gone from the database remove the # _legacy_select_events_context_id() - stmt = stmt.where(Events.context_id == context_id).union_all( + stmt += lambda s: s.where(Events.context_id == context_id).union_all( _states_query_for_context_id(start_day, end_day, context_id), legacy_select_events_context_id(start_day, end_day, context_id), ) else: if events_entity_filter is not None: - stmt = stmt.where(events_entity_filter) + stmt += lambda s: s.where(events_entity_filter) if states_entity_filter is not None: - stmt = stmt.union_all( + stmt += lambda s: s.union_all( _states_query_for_all(start_day, end_day).where(states_entity_filter) ) else: - stmt = stmt.union_all(_states_query_for_all(start_day, end_day)) + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) - return stmt.order_by(Events.time_fired) + stmt += lambda s: s.order_by(Events.time_fired) + return stmt def _states_query_for_all(start_day: dt, end_day: dt) -> Query: diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 56925b60e62..466df668da8 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -10,8 +10,7 @@ from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select -from homeassistant.components.recorder.filters import like_domain_matchers -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( EVENTS_CONTEXT_ID_INDEX, OLD_FORMAT_ATTRS_JSON, OLD_STATE, @@ -22,6 +21,7 @@ from homeassistant.components.recorder.models import ( StateAttributes, States, ) +from homeassistant.components.recorder.filters import like_domain_matchers from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS @@ -87,7 +87,7 @@ EVENT_COLUMNS_FOR_STATE_SELECT = [ ] EMPTY_STATE_COLUMNS = ( - literal(value=None, type_=sqlalchemy.String).label("state_id"), + literal(value=0, type_=sqlalchemy.Integer).label("state_id"), literal(value=None, type_=sqlalchemy.String).label("state"), literal(value=None, type_=sqlalchemy.String).label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 4c09720348f..e268c2d3ac3 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -4,12 +4,14 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt -from sqlalchemy import select +import sqlalchemy +from sqlalchemy import lambda_stmt, select from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList -from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( DEVICE_ID_IN_EVENT, EventData, Events, @@ -30,11 +32,11 @@ def _select_device_id_context_ids_sub_query( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quoted_device_ids: list[str], + json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple devices.""" inner = select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quoted_device_ids) + apply_event_device_id_matchers(json_quotable_device_ids) ) return select(inner.c.context_id).group_by(inner.c.context_id) @@ -44,14 +46,14 @@ def _apply_devices_context_union( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quoted_device_ids: list[str], + json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the device context ids and a query to find linked row.""" devices_cte: CTE = _select_device_id_context_ids_sub_query( start_day, end_day, event_types, - json_quoted_device_ids, + json_quotable_device_ids, ).cte() return query.union_all( apply_events_context_hints( @@ -71,22 +73,27 @@ def devices_stmt( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quoted_device_ids: list[str], -) -> Select: + json_quotable_device_ids: list[str], +) -> StatementLambdaElement: """Generate a logbook query for multiple devices.""" - return _apply_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quoted_device_ids) - ), - start_day, - end_day, - event_types, - json_quoted_device_ids, - ).order_by(Events.time_fired) + stmt = lambda_stmt( + lambda: _apply_devices_context_union( + select_events_without_states(start_day, end_day, event_types).where( + apply_event_device_id_matchers(json_quotable_device_ids) + ), + start_day, + end_day, + event_types, + json_quotable_device_ids, + ).order_by(Events.time_fired) + ) + return stmt def apply_event_device_id_matchers( - json_quoted_device_ids: Iterable[str], + json_quotable_device_ids: Iterable[str], ) -> ClauseList: """Create matchers for the device_ids in the event_data.""" - return DEVICE_ID_IN_EVENT.in_(json_quoted_device_ids) + return DEVICE_ID_IN_EVENT.is_not(None) & sqlalchemy.cast( + DEVICE_ID_IN_EVENT, sqlalchemy.Text() + ).in_(json_quotable_device_ids) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index a13a0f154e6..3803da6f4e8 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -5,11 +5,12 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query -from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( ENTITY_ID_IN_EVENT, ENTITY_ID_LAST_UPDATED_INDEX, OLD_ENTITY_ID_IN_EVENT, @@ -91,18 +92,20 @@ def entities_stmt( event_types: tuple[str, ...], entity_ids: list[str], json_quoted_entity_ids: list[str], -) -> Select: +) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" - return _apply_entities_context_union( - select_events_without_states(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quoted_entity_ids) - ), - start_day, - end_day, - event_types, - entity_ids, - json_quoted_entity_ids, - ).order_by(Events.time_fired) + return lambda_stmt( + lambda: _apply_entities_context_union( + select_events_without_states(start_day, end_day, event_types).where( + apply_event_entity_id_matchers(json_quoted_entity_ids) + ), + start_day, + end_day, + event_types, + entity_ids, + json_quoted_entity_ids, + ).order_by(Events.time_fired) + ) def states_query_for_entity_ids( @@ -118,8 +121,15 @@ def apply_event_entity_id_matchers( json_quoted_entity_ids: Iterable[str], ) -> sqlalchemy.or_: """Create matchers for the entity_id in the event_data.""" - return ENTITY_ID_IN_EVENT.in_(json_quoted_entity_ids) | OLD_ENTITY_ID_IN_EVENT.in_( - json_quoted_entity_ids + return sqlalchemy.or_( + ENTITY_ID_IN_EVENT.is_not(None) + & sqlalchemy.cast(ENTITY_ID_IN_EVENT, sqlalchemy.Text()).in_( + json_quoted_entity_ids + ), + OLD_ENTITY_ID_IN_EVENT.is_not(None) + & sqlalchemy.cast(OLD_ENTITY_ID_IN_EVENT, sqlalchemy.Text()).in_( + json_quoted_entity_ids + ), ) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 7514074cc85..f22a8392e19 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -5,11 +5,12 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query -from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import EventData, Events, States +from homeassistant.components.recorder.db_schema import EventData, Events, States from .common import ( apply_events_context_hints, @@ -93,21 +94,23 @@ def entities_devices_stmt( entity_ids: list[str], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], -) -> Select: +) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" - stmt = _apply_entities_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( - _apply_event_entity_id_device_id_matchers( - json_quoted_entity_ids, json_quoted_device_ids - ) - ), - start_day, - end_day, - event_types, - entity_ids, - json_quoted_entity_ids, - json_quoted_device_ids, - ).order_by(Events.time_fired) + stmt = lambda_stmt( + lambda: _apply_entities_devices_context_union( + select_events_without_states(start_day, end_day, event_types).where( + _apply_event_entity_id_device_id_matchers( + json_quoted_entity_ids, json_quoted_device_ids + ) + ), + start_day, + end_day, + event_types, + entity_ids, + json_quoted_entity_ids, + json_quoted_device_ids, + ).order_by(Events.time_fired) + ) return stmt diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index a8f9bc50920..3af87b26caa 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -14,10 +14,10 @@ from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util from .const import LOGBOOK_ENTITIES_FILTER @@ -301,7 +301,7 @@ async def ws_event_stream( entity_ids = msg.get("entity_ids") if entity_ids: entity_ids = async_filter_entities(hass, entity_ids) - if not entity_ids: + if not entity_ids and not device_ids: _async_send_empty_response(connection, msg_id, start_time, end_time) return diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index eed2ec45dd7..e3223eb109f 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -2,7 +2,7 @@ "domain": "london_underground", "name": "London Underground", "documentation": "https://www.home-assistant.io/integrations/london_underground", - "requirements": ["london-tube-status==0.2"], + "requirements": ["london-tube-status==0.5"], "codeowners": [], "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 a73909295b6..a4cc66a8447 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant +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 @@ -43,21 +44,24 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Tube sensor.""" - data = TubeData() - data.update() + session = async_get_clientsession(hass) + + data = TubeData(session) + await data.update() + sensors = [] for line in config[CONF_LINE]: sensors.append(LondonTubeSensor(line, data)) - add_entities(sensors, True) + async_add_entities(sensors, True) class LondonTubeSensor(SensorEntity): @@ -92,8 +96,8 @@ class LondonTubeSensor(SensorEntity): self.attrs["Description"] = self._description return self.attrs - def update(self): + async def async_update(self): """Update the sensor.""" - self._data.update() + await self._data.update() self._state = self._data.data[self.name]["State"] self._description = self._data.data[self.name]["Description"] diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 555b8b551be..9b0a5b05f1f 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -23,6 +23,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS, TYPE_TO_PLATFORM @@ -182,3 +183,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER] await manager.async_stop() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove lookin config entry from a device.""" + data: LookinData = hass.data[DOMAIN][entry.entry_id] + all_identifiers: set[tuple[str, str]] = { + (DOMAIN, data.lookin_device.id), + *((DOMAIN, remote["UUID"]) for remote in data.devices), + } + return not any( + identifier + for identifier in device_entry.identifiers + if identifier in all_identifiers + ) diff --git a/homeassistant/components/lookin/translations/fr.json b/homeassistant/components/lookin/translations/fr.json index 7276af22624..2ceb9bd6600 100644 --- a/homeassistant/components/lookin/translations/fr.json +++ b/homeassistant/components/lookin/translations/fr.json @@ -19,7 +19,7 @@ } }, "discovery_confirm": { - "description": "Voulez-vous configurer {name} ( {host} )?" + "description": "Voulez-vous configurer {name} ({host})\u00a0?" }, "user": { "data": { diff --git a/homeassistant/components/lovelace/translations/pt-BR.json b/homeassistant/components/lovelace/translations/pt-BR.json index 2ff25d17161..dd8cc7cc32d 100644 --- a/homeassistant/components/lovelace/translations/pt-BR.json +++ b/homeassistant/components/lovelace/translations/pt-BR.json @@ -1,7 +1,7 @@ { "system_health": { "info": { - "dashboards": "Pain\u00e9is", + "dashboards": "Dashboards", "mode": "Modo", "resources": "Recursos", "views": "Visualiza\u00e7\u00f5es" diff --git a/homeassistant/components/luftdaten/translations/pt-BR.json b/homeassistant/components/luftdaten/translations/pt-BR.json index 877cc5d133c..d26faf40b3e 100644 --- a/homeassistant/components/luftdaten/translations/pt-BR.json +++ b/homeassistant/components/luftdaten/translations/pt-BR.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "show_on_map": "[%key:component::iss::config::step::user::data::show_on_map%]", + "show_on_map": "Mostrar no mapa", "station_id": "ID do Sensor Luftdaten" } } diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 812225ea407..2ae0b5944bd 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -19,8 +19,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice -ICON = "mdi:security" - SCAN_INTERVAL = timedelta(seconds=2) @@ -44,18 +42,14 @@ def setup_platform( class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): """An alarm_control_panel implementation for Lupusec.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) @property - def icon(self): - """Return the icon.""" - return ICON - - @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if self._device.is_standby: state = STATE_ALARM_DISARMED @@ -69,14 +63,14 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): state = None return state - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._device.set_away() - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._device.set_standby() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._device.set_home() diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 45b7751aa7c..65a1c737d55 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, @@ -42,36 +43,36 @@ class LutronCover(LutronDevice, CoverEntity): ) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._lutron_device.last_level() < 1 @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of cover.""" return self._lutron_device.last_level() - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._lutron_device.level = 0 - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._lutron_device.level = 100 - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] self._lutron_device.level = position - def update(self): + def update(self) -> None: """Call when forcing a refresh of the device.""" # Reading the property (rather than last_level()) fetches value level = self._lutron_device.level _LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index dd64ed4ec6f..c6ad8781478 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio import contextlib +from itertools import chain import logging import ssl +from typing import Any import async_timeout from pylutron_caseta import BUTTON_STATUS_PRESSED @@ -15,7 +17,7 @@ from homeassistant import config_entries from homeassistant.const import ATTR_DEVICE_ID, ATTR_SUGGESTED_AREA, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType @@ -30,11 +32,8 @@ from .const import ( ATTR_LEAP_BUTTON_NUMBER, ATTR_SERIAL, ATTR_TYPE, - BRIDGE_DEVICE, BRIDGE_DEVICE_ID, - BRIDGE_LEAP, BRIDGE_TIMEOUT, - BUTTON_DEVICES, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, @@ -48,6 +47,8 @@ from .device_trigger import ( DEVICE_TYPE_SUBTYPE_MAP_TO_LIP, LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP, ) +from .models import LutronCasetaData +from .util import serial_to_unique_id _LOGGER = logging.getLogger(__name__) @@ -104,6 +105,33 @@ async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: return True +async def _async_migrate_unique_ids( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> None: + """Migrate entities since the occupancygroup were not actually unique.""" + + dev_reg = dr.async_get(hass) + bridge_unique_id = entry.unique_id + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + if not (unique_id := entity_entry.unique_id): + return None + if not unique_id.startswith("occupancygroup_") or unique_id.startswith( + f"occupancygroup_{bridge_unique_id}" + ): + return None + sensor_id = unique_id.split("_")[1] + new_unique_id = f"occupancygroup_{bridge_unique_id}_{sensor_id}" + if dev_entry := dev_reg.async_get_device({(DOMAIN, unique_id)}): + dev_reg.async_update_device( + dev_entry.id, new_identifiers={(DOMAIN, new_unique_id)} + ) + return {"new_unique_id": f"occupancygroup_{bridge_unique_id}_{sensor_id}"} + + await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) + + async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry ) -> bool: @@ -115,6 +143,8 @@ async def async_setup_entry( ca_certs = hass.config.path(config_entry.data[CONF_CA_CERTS]) bridge = None + await _async_migrate_unique_ids(hass, config_entry) + try: bridge = Smartbridge.create_tls( hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs @@ -142,7 +172,7 @@ async def async_setup_entry( bridge_device = devices[BRIDGE_DEVICE_ID] if not config_entry.unique_id: hass.config_entries.async_update_entry( - config_entry, unique_id=hex(bridge_device["serial"])[2:].zfill(8) + config_entry, unique_id=serial_to_unique_id(bridge_device["serial"]) ) buttons = bridge.buttons @@ -154,11 +184,9 @@ async def async_setup_entry( # Store this bridge (keyed by entry_id) so it can be retrieved by the # platforms we're setting up. - hass.data[DOMAIN][entry_id] = { - BRIDGE_LEAP: bridge, - BRIDGE_DEVICE: bridge_device, - BUTTON_DEVICES: button_devices, - } + hass.data[DOMAIN][entry_id] = LutronCasetaData( + bridge, bridge_device, button_devices + ) hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -187,10 +215,10 @@ def _async_register_button_devices( config_entry_id: str, bridge_device, button_devices_by_id: dict[int, dict], -) -> dict[str, dr.DeviceEntry]: +) -> dict[str, dict]: """Register button devices (Pico Remotes) in the device registry.""" device_registry = dr.async_get(hass) - button_devices_by_dr_id = {} + button_devices_by_dr_id: dict[str, dict] = {} seen = set() for device in button_devices_by_id.values(): @@ -198,7 +226,7 @@ def _async_register_button_devices( continue seen.add(device["serial"]) area, name = _area_and_name_from_name(device["name"]) - device_args = { + device_args: dict[str, Any] = { "name": f"{area} {name}", "manufacturer": MANUFACTURER, "config_entry_id": config_entry_id, @@ -218,7 +246,8 @@ def _async_register_button_devices( def _area_and_name_from_name(device_name: str) -> tuple[str, str]: """Return the area and name from the devices internal name.""" if "_" in device_name: - return device_name.split("_", 1) + area_device_name = device_name.split("_", 1) + return area_device_name[0], area_device_name[1] return UNASSIGNED_AREA, device_name @@ -287,9 +316,8 @@ async def async_unload_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Unload the bridge bridge from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] - smartbridge: Smartbridge = data[BRIDGE_LEAP] - await smartbridge.close() + data: LutronCasetaData = hass.data[DOMAIN][entry.entry_id] + await data.bridge.close() if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -310,6 +338,7 @@ class LutronCasetaDevice(Entity): self._device = device self._smartbridge = bridge self._bridge_device = bridge_device + self._bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) if "serial" not in self._device: return area, name = _area_and_name_from_name(device["name"]) @@ -354,7 +383,46 @@ class LutronCasetaDevice(Entity): class LutronCasetaDeviceUpdatableEntity(LutronCasetaDevice): """A lutron_caseta entity that can update by syncing data from the bridge.""" - async def async_update(self): + async def async_update(self) -> None: """Update when forcing a refresh of the device.""" self._device = self._smartbridge.get_device_by_id(self.device_id) _LOGGER.debug(self._device) + + +def _id_to_identifier(lutron_id: str) -> tuple[str, str]: + """Convert a lutron caseta identifier to a device identifier.""" + return (DOMAIN, lutron_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: config_entries.ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove lutron_caseta config entry from a device.""" + data: LutronCasetaData = hass.data[DOMAIN][entry.entry_id] + bridge = data.bridge + devices = bridge.get_devices() + buttons = bridge.buttons + occupancy_groups = bridge.occupancy_groups + bridge_device = devices[BRIDGE_DEVICE_ID] + bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) + all_identifiers: set[tuple[str, str]] = { + # Base bridge + _id_to_identifier(bridge_unique_id), + # Motion sensors and occupancy groups + *( + _id_to_identifier( + f"occupancygroup_{bridge_unique_id}_{device['occupancy_group_id']}" + ) + for device in occupancy_groups.values() + ), + # Button devices such as pico remotes and all other devices + *( + _id_to_identifier(device["serial"]) + for device in chain(devices.values(), buttons.values()) + ), + } + return not any( + identifier + for identifier in device_entry.identifiers + if identifier in all_identifiers + ) diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 6a6e3853280..4b1c53d194b 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -6,14 +6,14 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SUGGESTED_AREA from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice, _area_and_name_from_name -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA +from .const import CONFIG_URL, MANUFACTURER +from .models import LutronCasetaData async def async_setup_entry( @@ -26,9 +26,9 @@ async def async_setup_entry( Adds occupancy groups from the Caseta bridge associated with the config_entry as binary_sensor entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device occupancy_groups = bridge.occupancy_groups async_add_entities( LutronOccupancySensor(occupancy_group, bridge, bridge_device) @@ -44,7 +44,9 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): def __init__(self, device, bridge, bridge_device): """Init an occupancy sensor.""" super().__init__(device, bridge, bridge_device) - info = DeviceInfo( + _, name = _area_and_name_from_name(device["name"]) + self._attr_name = name + self._attr_device_info = DeviceInfo( identifiers={(CASETA_DOMAIN, self.unique_id)}, manufacturer=MANUFACTURER, model="Lutron Occupancy", @@ -53,10 +55,6 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): configuration_url=CONFIG_URL, entry_type=DeviceEntryType.SERVICE, ) - area, _ = _area_and_name_from_name(device["name"]) - if area != UNASSIGNED_AREA: - info[ATTR_SUGGESTED_AREA] = area - self._attr_device_info = info @property def is_on(self): @@ -77,7 +75,7 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): @property def unique_id(self): """Return a unique identifier.""" - return f"occupancygroup_{self.device_id}" + return f"occupancygroup_{self._bridge_unique_id}_{self.device_id}" @property def extra_state_attributes(self): diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 71d686ba2c8..ae8dc0a505a 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -10,9 +10,6 @@ STEP_IMPORT_FAILED = "import_failed" ERROR_CANNOT_CONNECT = "cannot_connect" ABORT_REASON_CANNOT_CONNECT = "cannot_connect" -BRIDGE_LEAP = "leap" -BRIDGE_DEVICE = "bridge_device" -BUTTON_DEVICES = "button_devices" LUTRON_CASETA_BUTTON_EVENT = "lutron_caseta_button_event" BRIDGE_DEVICE_ID = "1" diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index afddb2677a7..d63c1191d57 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -1,5 +1,7 @@ """Support for Lutron Caseta shades.""" +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN, @@ -12,7 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData async def async_setup_entry( @@ -25,9 +28,9 @@ async def async_setup_entry( Adds shades from the Caseta bridge associated with the config_entry as cover entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device cover_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaCover(cover_device, bridge, bridge_device) @@ -47,32 +50,32 @@ class LutronCasetaCover(LutronCasetaDeviceUpdatableEntity, CoverEntity): _attr_device_class = CoverDeviceClass.SHADE @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._device["current_state"] < 1 @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of cover.""" return self._device["current_state"] - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Top the cover.""" await self._smartbridge.stop_cover(self.device_id) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._smartbridge.lower_cover(self.device_id) - self.async_update() + await self.async_update() self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._smartbridge.raise_cover(self.device_id) - self.async_update() + await self.async_update() self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 68394667764..ed809e0994a 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -29,11 +29,11 @@ from .const import ( ATTR_ACTION, ATTR_BUTTON_NUMBER, ATTR_SERIAL, - BUTTON_DEVICES, CONF_SUBTYPE, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, ) +from .models import LutronCasetaData SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE] @@ -386,10 +386,12 @@ async def async_attach_trigger( """Attach a trigger.""" device_registry = dr.async_get(hass) device = device_registry.async_get(config[CONF_DEVICE_ID]) + assert device + assert device.model device_type = _device_model_to_type(device.model) _, serial = list(device.identifiers)[0] - schema = DEVICE_TYPE_SCHEMA_MAP.get(device_type) - valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LIP.get(device_type) + schema = DEVICE_TYPE_SCHEMA_MAP[device_type] + valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LIP[device_type] config = schema(config) event_config = { event_trigger.CONF_PLATFORM: CONF_EVENT, @@ -411,9 +413,9 @@ def get_button_device_by_dr_id(hass: HomeAssistant, device_id: str): if DOMAIN not in hass.data: return None - for config_entry in hass.data[DOMAIN]: - button_devices = hass.data[DOMAIN][config_entry][BUTTON_DEVICES] - if device := button_devices.get(device_id): + for entry_id in hass.data[DOMAIN]: + data: LutronCasetaData = hass.data[DOMAIN][entry_id] + if device := data.button_devices.get(device_id): return device return None diff --git a/homeassistant/components/lutron_caseta/diagnostics.py b/homeassistant/components/lutron_caseta/diagnostics.py index 7ae0b5c40a9..afe69b813f9 100644 --- a/homeassistant/components/lutron_caseta/diagnostics.py +++ b/homeassistant/components/lutron_caseta/diagnostics.py @@ -3,19 +3,19 @@ from __future__ import annotations from typing import Any -from pylutron_caseta.smartbridge import Smartbridge - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import BRIDGE_LEAP, DOMAIN +from .const import DOMAIN +from .models import LutronCasetaData async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - bridge: Smartbridge = hass.data[DOMAIN][entry.entry_id][BRIDGE_LEAP] + data: LutronCasetaData = hass.data[DOMAIN][entry.entry_id] + bridge = data.bridge return { "entry": { "title": entry.title, diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index cdf00959ed1..bf2328565d4 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -1,6 +1,8 @@ """Support for Lutron Caseta fans.""" from __future__ import annotations +from typing import Any + from pylutron_caseta import FAN_HIGH, FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_OFF from homeassistant.components.fan import DOMAIN, FanEntity, FanEntityFeature @@ -13,7 +15,8 @@ from homeassistant.util.percentage import ( ) from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData DEFAULT_ON_PERCENTAGE = 50 ORDERED_NAMED_FAN_SPEEDS = [FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH] @@ -29,9 +32,9 @@ async def async_setup_entry( Adds fan controllers from the Caseta bridge associated with the config_entry as fan entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device fan_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaFan(fan_device, bridge, bridge_device) for fan_device in fan_devices @@ -57,17 +60,17 @@ class LutronCasetaFan(LutronCasetaDeviceUpdatableEntity, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, - ): + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the fan on.""" if percentage is None: percentage = DEFAULT_ON_PERCENTAGE await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self.async_set_percentage(0) @@ -83,6 +86,6 @@ class LutronCasetaFan(LutronCasetaDeviceUpdatableEntity, FanEntity): await self._smartbridge.set_fan(self.device_id, named_speed) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" - return self.percentage and self.percentage > 0 + return bool(self.percentage) diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index a58fc21aadf..9fbb80284f5 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -14,7 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData def to_lutron_level(level): @@ -37,9 +38,9 @@ async def async_setup_entry( Adds dimmers from the Caseta bridge associated with the config_entry as light entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device light_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaLight(light_device, bridge, bridge_device) diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py new file mode 100644 index 00000000000..362760b0caf --- /dev/null +++ b/homeassistant/components/lutron_caseta/models.py @@ -0,0 +1,16 @@ +"""The lutron_caseta integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pylutron_caseta.smartbridge import Smartbridge + + +@dataclass +class LutronCasetaData: + """Data for the lutron_caseta integration.""" + + bridge: Smartbridge + bridge_device: dict[str, Any] + button_devices: dict[str, dict] diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index d73d8011481..2870d6ee96a 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -1,12 +1,18 @@ """Support for Lutron Caseta scenes.""" from typing import Any +from pylutron_caseta.smartbridge import Smartbridge + from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from . import _area_and_name_from_name +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData +from .util import serial_to_unique_id async def async_setup_entry( @@ -19,20 +25,28 @@ async def async_setup_entry( Adds scenes from the Caseta bridge associated with the config_entry as scene entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device scenes = bridge.get_scenes() - async_add_entities(LutronCasetaScene(scenes[scene], bridge) for scene in scenes) + async_add_entities( + LutronCasetaScene(scenes[scene], bridge, bridge_device) for scene in scenes + ) class LutronCasetaScene(Scene): """Representation of a Lutron Caseta scene.""" - def __init__(self, scene, bridge): + def __init__(self, scene, bridge, bridge_device): """Initialize the Lutron Caseta scene.""" - self._attr_name = scene["name"] self._scene_id = scene["scene_id"] - self._bridge = bridge + self._bridge: Smartbridge = bridge + bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) + self._attr_device_info = DeviceInfo( + identifiers={(CASETA_DOMAIN, bridge_device["serial"])}, + ) + self._attr_name = _area_and_name_from_name(scene["name"])[1] + self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 7e963352264..062c8891672 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -6,7 +6,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData async def async_setup_entry( @@ -19,9 +20,9 @@ async def async_setup_entry( Adds switches from the Caseta bridge associated with the config_entry as switch entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device switch_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaLight(switch_device, bridge, bridge_device) diff --git a/homeassistant/components/lutron_caseta/util.py b/homeassistant/components/lutron_caseta/util.py new file mode 100644 index 00000000000..dfcf7a32228 --- /dev/null +++ b/homeassistant/components/lutron_caseta/util.py @@ -0,0 +1,7 @@ +"""Support for Lutron Caseta.""" +from __future__ import annotations + + +def serial_to_unique_id(serial: int) -> str: + """Convert a lutron serial number to a unique id.""" + return hex(serial)[2:].zfill(8) diff --git a/homeassistant/components/lyric/config_flow.py b/homeassistant/components/lyric/config_flow.py index 698e7e19a26..12f91cfe206 100644 --- a/homeassistant/components/lyric/config_flow.py +++ b/homeassistant/components/lyric/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Honeywell Lyric.""" +from collections.abc import Mapping import logging +from typing import Any +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN @@ -18,7 +21,7 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - async def async_step_reauth(self, user_input=None): + 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() diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index dd347336d9e..9a5d84f5997 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -5,6 +5,7 @@ import copy import datetime import logging import re +from typing import Any import voluptuous as vol @@ -185,6 +186,16 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): A trigger_time of zero disables the alarm_trigger service. """ + _attr_should_poll = False + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION + | AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ) + def __init__( self, hass, @@ -198,13 +209,13 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass - self._name = name + self._attr_name = name if code_template: self._code = code_template self._code.hass = hass else: self._code = code or None - self._code_arm_required = code_arm_required + self._attr_code_arm_required = code_arm_required self._disarm_after_trigger = disarm_after_trigger self._previous_state = self._state self._state_ts = None @@ -222,17 +233,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): } @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): @@ -253,18 +254,6 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return self._state - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_VACATION - | AlarmControlPanelEntityFeature.TRIGGER - | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - ) - @property def _active_state(self): """Get the current state.""" @@ -289,7 +278,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property - def code_format(self): + def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None @@ -297,12 +286,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): return @@ -311,52 +295,52 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_HOME ): return self._update_state(STATE_ALARM_ARMED_HOME) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_AWAY ): return self._update_state(STATE_ALARM_ARMED_AWAY) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_NIGHT ): return self._update_state(STATE_ALARM_ARMED_NIGHT) - def alarm_arm_vacation(self, code=None): + def alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_VACATION ): return self._update_state(STATE_ALARM_ARMED_VACATION) - def alarm_arm_custom_bypass(self, code=None): + def alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_CUSTOM_BYPASS ): return self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) - def alarm_trigger(self, code=None): + def alarm_trigger(self, code: str | None = None) -> None: """ Send alarm trigger command. @@ -367,7 +351,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return self._update_state(STATE_ALARM_TRIGGERED) - def _update_state(self, state): + def _update_state(self, state: str) -> None: """Update the state.""" if self._state == state: return @@ -414,7 +398,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return check @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING): return { @@ -428,7 +412,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """Update state at a scheduled point in time.""" self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 67675a44e22..66c75b5ed0e 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -5,6 +5,7 @@ import copy import datetime import logging import re +from typing import Any import voluptuous as vol @@ -204,6 +205,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): A trigger_time of zero disables the alarm_trigger service. """ + _attr_should_poll = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -231,7 +233,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass - self._name = name + self._attr_name = name if code_template: self._code = code_template self._code.hass = hass @@ -257,24 +259,14 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._state_topic = state_topic self._command_topic = command_topic self._qos = qos - self._code_arm_required = code_arm_required + self._attr_code_arm_required = code_arm_required self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away self._payload_arm_night = payload_arm_night @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): @@ -314,7 +306,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property - def code_format(self): + def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None @@ -322,12 +314,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): return @@ -336,34 +323,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_HOME ): return self._update_state(STATE_ALARM_ARMED_HOME) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_AWAY ): return self._update_state(STATE_ALARM_ARMED_AWAY) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_NIGHT ): return self._update_state(STATE_ALARM_ARMED_NIGHT) - def alarm_trigger(self, code=None): + def alarm_trigger(self, code: str | None = None) -> None: """ Send alarm trigger command. @@ -417,7 +404,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): return check @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.state != STATE_ALARM_PENDING: return {} @@ -426,7 +413,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): ATTR_POST_PENDING_STATE: self._state, } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" async_track_state_change_event( self.hass, [self.entity_id], self._async_state_changed_listener diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 2af4e46bb1a..85b9700a624 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -33,7 +33,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN, SERVICES +from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -109,34 +109,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if vehicle_id == 0 or api_client is None: raise HomeAssistantError("Vehicle ID not found") - if service_call.service in ( - "start_engine", - "stop_engine", - "turn_on_hazard_lights", - "turn_off_hazard_lights", - ): - _LOGGER.warning( - "The mazda.%s service is deprecated and has been replaced by a button entity; " - "Please use the button entity instead", - service_call.service, - ) - - if service_call.service in ("start_charging", "stop_charging"): - _LOGGER.warning( - "The mazda.%s service is deprecated and has been replaced by a switch entity; " - "Please use the charging switch entity instead", - service_call.service, - ) - api_method = getattr(api_client, service_call.service) try: - if service_call.service == "send_poi": - latitude = service_call.data["latitude"] - longitude = service_call.data["longitude"] - poi_name = service_call.data["poi_name"] - await api_method(vehicle_id, latitude, longitude, poi_name) - else: - await api_method(vehicle_id) + latitude = service_call.data["latitude"] + longitude = service_call.data["longitude"] + poi_name = service_call.data["poi_name"] + await api_method(vehicle_id, latitude, longitude, poi_name) except Exception as ex: raise HomeAssistantError(ex) from ex @@ -157,12 +135,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return device_id - service_schema = vol.Schema( - {vol.Required("device_id"): vol.All(cv.string, validate_mazda_device_id)} - ) - - service_schema_send_poi = service_schema.extend( + service_schema_send_poi = vol.Schema( { + vol.Required("device_id"): vol.All(cv.string, validate_mazda_device_id), vol.Required("latitude"): cv.latitude, vol.Required("longitude"): cv.longitude, vol.Required("poi_name"): cv.string, @@ -220,13 +195,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) # Register services - for service in SERVICES: - hass.services.async_register( - DOMAIN, - service, - async_handle_service_call, - schema=service_schema_send_poi if service == "send_poi" else service_schema, - ) + hass.services.async_register( + DOMAIN, + "send_poi", + async_handle_service_call, + schema=service_schema_send_poi, + ) return True @@ -237,8 +211,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only remove services if it is the last config entry if len(hass.data[DOMAIN]) == 1: - for service in SERVICES: - hass.services.async_remove(DOMAIN, service) + hass.services.async_remove(DOMAIN, "send_poi") if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/mazda/config_flow.py b/homeassistant/components/mazda/config_flow.py index b1b0ce35b11..0b255483da1 100644 --- a/homeassistant/components/mazda/config_flow.py +++ b/homeassistant/components/mazda/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Mazda Connected Services integration.""" +from collections.abc import Mapping import logging +from typing import Any import aiohttp from pymazda import ( @@ -11,6 +13,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN, MAZDA_REGIONS @@ -97,11 +100,11 @@ class MazdaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth if the user credentials have changed.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - self._email = user_input[CONF_EMAIL] - self._region = user_input[CONF_REGION] + self._email = entry_data[CONF_EMAIL] + self._region = entry_data[CONF_REGION] return await self.async_step_user() diff --git a/homeassistant/components/mazda/const.py b/homeassistant/components/mazda/const.py index 5baeef3102d..58ca2183a56 100644 --- a/homeassistant/components/mazda/const.py +++ b/homeassistant/components/mazda/const.py @@ -7,13 +7,3 @@ DATA_COORDINATOR = "coordinator" DATA_VEHICLES = "vehicles" MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} - -SERVICES = [ - "send_poi", - "start_charging", - "start_engine", - "stop_charging", - "stop_engine", - "turn_off_hazard_lights", - "turn_on_hazard_lights", -] diff --git a/homeassistant/components/mazda/lock.py b/homeassistant/components/mazda/lock.py index 4b51a9eb97e..bcd409d2faf 100644 --- a/homeassistant/components/mazda/lock.py +++ b/homeassistant/components/mazda/lock.py @@ -1,4 +1,8 @@ """Platform for Mazda lock integration.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -36,17 +40,17 @@ class MazdaLock(MazdaEntity, LockEntity): self._attr_unique_id = self.vin @property - def is_locked(self): + def is_locked(self) -> bool | None: """Return true if lock is locked.""" return self.client.get_assumed_lock_state(self.vehicle_id) - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the vehicle doors.""" await self.client.lock_doors(self.vehicle_id) self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the vehicle doors.""" await self.client.unlock_doors(self.vehicle_id) diff --git a/homeassistant/components/mazda/services.yaml b/homeassistant/components/mazda/services.yaml index 80d8c2f64f6..1abf8bd5dea 100644 --- a/homeassistant/components/mazda/services.yaml +++ b/homeassistant/components/mazda/services.yaml @@ -1,47 +1,3 @@ -start_engine: - name: Start engine - description: Start the vehicle engine. - fields: - device_id: - name: Vehicle - description: The vehicle to start - required: true - selector: - device: - integration: mazda -stop_engine: - name: Stop engine - description: Stop the vehicle engine. - fields: - device_id: - name: Vehicle - description: The vehicle to stop - required: true - selector: - device: - integration: mazda -turn_on_hazard_lights: - name: Turn on hazard lights - description: Turn on the vehicle hazard lights. The lights will flash briefly and then turn off. - fields: - device_id: - name: Vehicle - description: The vehicle to turn hazard lights on - required: true - selector: - device: - integration: mazda -turn_off_hazard_lights: - name: Turn off hazard lights - description: Turn off the vehicle hazard lights if they have been manually turned on from inside the vehicle. - fields: - device_id: - name: Vehicle - description: The vehicle to turn hazard lights off - required: true - selector: - device: - integration: mazda send_poi: name: Send POI description: Send a GPS location to the vehicle's navigation system as a POI (Point of Interest). Requires a navigation SD card installed in the vehicle. @@ -82,25 +38,3 @@ send_poi: required: true selector: text: -start_charging: - name: Start charging - description: Start charging the vehicle. For electric vehicles only. - fields: - device_id: - name: Vehicle - description: The vehicle to start charging - required: true - selector: - device: - integration: mazda -stop_charging: - name: Stop charging - description: Stop charging the vehicle. For electric vehicles only. - fields: - device_id: - name: Vehicle - description: The vehicle to stop charging - required: true - selector: - device: - integration: mazda diff --git a/homeassistant/components/mazda/translations/bg.json b/homeassistant/components/mazda/translations/bg.json index e51e1112202..6e9ce8d9a6a 100644 --- a/homeassistant/components/mazda/translations/bg.json +++ b/homeassistant/components/mazda/translations/bg.json @@ -1,11 +1,13 @@ { "config": { "error": { + "account_locked": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0435 \u0437\u0430\u043a\u043b\u044e\u0447\u0435\u043d. \u041c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e.", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { "data": { + "email": "Email", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "region": "\u0420\u0435\u0433\u0438\u043e\u043d" } diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index 07dbd4bd4a5..91a927a5fb2 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Meater.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from meater import AuthenticationError, MeaterApi, ServiceUnavailableError import voluptuous as vol @@ -42,10 +45,10 @@ class MeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._try_connect_meater("user", None, username, password) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._data_schema = REAUTH_SCHEMA - self._username = data[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/meater/translations/es.json b/homeassistant/components/meater/translations/es.json index 39d35b38d4a..44e5f3984f4 100644 --- a/homeassistant/components/meater/translations/es.json +++ b/homeassistant/components/meater/translations/es.json @@ -1,18 +1,26 @@ { "config": { "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "service_unavailable_error": "La API no est\u00e1 disponible actualmente, vuelva a intentarlo m\u00e1s tarde.", "unknown_auth_error": "Error inesperado" }, "step": { "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + }, "description": "Confirma la contrase\u00f1a de la cuenta de Meater Cloud {username}." }, "user": { "data": { "password": "Contrase\u00f1a", "username": "Usuario" - } + }, + "data_description": { + "username": "Nombre de usuario de Meater Cloud, normalmente una direcci\u00f3n de correo electr\u00f3nico." + }, + "description": "Configure su cuenta de Meater Cloud." } } } diff --git a/homeassistant/components/meater/translations/sv.json b/homeassistant/components/meater/translations/sv.json index 383fbbeb5a6..47a719743f5 100644 --- a/homeassistant/components/meater/translations/sv.json +++ b/homeassistant/components/meater/translations/sv.json @@ -1,5 +1,9 @@ { "config": { + "error": { + "invalid_auth": "Ogiltig autentisering", + "service_unavailable_error": "Programmeringsgr\u00e4nssnittet g\u00e5r inte att komma \u00e5t f\u00f6r n\u00e4rvarande. F\u00f6rs\u00f6k igen senare." + }, "step": { "reauth_confirm": { "data": { @@ -8,6 +12,9 @@ "description": "Bekr\u00e4fta l\u00f6senordet f\u00f6r Meater Cloud-kontot {username}." }, "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + }, "data_description": { "username": "Meater Cloud anv\u00e4ndarnamn, vanligtvis en e-postadress." } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index dc2f3624a0e..14546a36ec8 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -52,6 +52,7 @@ from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PLAYING, + STATE_STANDBY, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -888,7 +889,7 @@ class MediaPlayerEntity(Entity): await self.hass.async_add_executor_job(self.toggle) return - if self.state in (STATE_OFF, STATE_IDLE): + if self.state in (STATE_OFF, STATE_IDLE, STATE_STANDBY): await self.async_turn_on() else: await self.async_turn_off() diff --git a/homeassistant/components/media_player/translations/zh-Hans.json b/homeassistant/components/media_player/translations/zh-Hans.json index 0fa034898c3..fe1ec28d8a1 100644 --- a/homeassistant/components/media_player/translations/zh-Hans.json +++ b/homeassistant/components/media_player/translations/zh-Hans.json @@ -1,6 +1,7 @@ { "device_automation": { "condition_type": { + "is_buffering": "{entity_name} \u6b63\u5728\u7f13\u51b2", "is_idle": "{entity_name} \u7a7a\u95f2", "is_off": "{entity_name} \u5df2\u5173\u95ed", "is_on": "{entity_name} \u5df2\u5f00\u542f", @@ -8,6 +9,8 @@ "is_playing": "{entity_name} \u6b63\u5728\u64ad\u653e" }, "trigger_type": { + "buffering": "{entity_name} \u5f00\u59cb\u7f13\u51b2", + "changed_states": "{entity_name} \u72b6\u6001\u53d8\u5316", "idle": "{entity_name} \u7a7a\u95f2", "paused": "{entity_name} \u6682\u505c", "playing": "{entity_name} \u5f00\u59cb\u64ad\u653e", diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index 93f9e3414dd..5b2a756847e 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -11,13 +11,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, @@ -173,13 +173,13 @@ CONDITIONS_MAP = { FORECAST_MAP = { ATTR_FORECAST_CONDITION: "condition", - ATTR_FORECAST_PRECIPITATION: "precipitation", + ATTR_FORECAST_NATIVE_PRECIPITATION: "precipitation", ATTR_FORECAST_PRECIPITATION_PROBABILITY: "precipitation_probability", - ATTR_FORECAST_TEMP: "temperature", - ATTR_FORECAST_TEMP_LOW: "templow", + ATTR_FORECAST_NATIVE_TEMP: "temperature", + ATTR_FORECAST_NATIVE_TEMP_LOW: "templow", ATTR_FORECAST_TIME: "datetime", ATTR_FORECAST_WIND_BEARING: "wind_bearing", - ATTR_FORECAST_WIND_SPEED: "wind_speed", + ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", } ATTR_MAP = { diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 251d99ad295..0ff0a60bfa1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -6,7 +6,6 @@ from typing import Any from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, @@ -21,12 +20,9 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - LENGTH_INCHES, LENGTH_MILLIMETERS, PRESSURE_HPA, - PRESSURE_INHG, SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -34,19 +30,9 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.distance import convert as convert_distance -from homeassistant.util.pressure import convert as convert_pressure -from homeassistant.util.speed import convert as convert_speed from . import MetDataUpdateCoordinator -from .const import ( - ATTR_FORECAST_PRECIPITATION, - ATTR_MAP, - CONDITIONS_MAP, - CONF_TRACK_HOME, - DOMAIN, - FORECAST_MAP, -) +from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP ATTRIBUTION = ( "Weather forecast from met.no, delivered by the Norwegian " @@ -85,6 +71,11 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__( self, coordinator: MetDataUpdateCoordinator, @@ -144,27 +135,18 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): return format_condition(condition) @property - def temperature(self) -> float | None: + def native_temperature(self) -> float | None: """Return the temperature.""" return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_TEMPERATURE] ) @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self) -> float | None: + def native_pressure(self) -> float | None: """Return the pressure.""" - pressure_hpa = self.coordinator.data.current_weather_data.get( + return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_PRESSURE] ) - if self._is_metric or pressure_hpa is None: - return pressure_hpa - - return round(convert_pressure(pressure_hpa, PRESSURE_HPA, PRESSURE_INHG), 2) @property def humidity(self) -> float | None: @@ -174,18 +156,11 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ) @property - def wind_speed(self) -> float | None: + def native_wind_speed(self) -> float | None: """Return the wind speed.""" - speed_km_h = self.coordinator.data.current_weather_data.get( + return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_WIND_SPEED] ) - if self._is_metric or speed_km_h is None: - return speed_km_h - - speed_mi_h = convert_speed( - speed_km_h, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR - ) - return int(round(speed_mi_h)) @property def wind_bearing(self) -> float | str | None: @@ -206,7 +181,7 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): met_forecast = self.coordinator.data.hourly_forecast else: met_forecast = self.coordinator.data.daily_forecast - required_keys = {ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME} + required_keys = {"temperature", ATTR_FORECAST_TIME} ha_forecast: list[Forecast] = [] for met_item in met_forecast: if not set(met_item).issuperset(required_keys): @@ -216,14 +191,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): for k, v in FORECAST_MAP.items() if met_item.get(v) is not None } - if not self._is_metric and ATTR_FORECAST_PRECIPITATION in ha_item: - if ha_item[ATTR_FORECAST_PRECIPITATION] is not None: - precip_inches = convert_distance( - ha_item[ATTR_FORECAST_PRECIPITATION], - LENGTH_MILLIMETERS, - LENGTH_INCHES, - ) - ha_item[ATTR_FORECAST_PRECIPITATION] = round(precip_inches, 2) if ha_item.get(ATTR_FORECAST_CONDITION): ha_item[ATTR_FORECAST_CONDITION] = format_condition( ha_item[ATTR_FORECAST_CONDITION] diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index 98d862183c4..efe80cb9d17 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -1,6 +1,4 @@ """Constants for Met Éireann component.""" -import logging - from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -12,13 +10,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRESSURE, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, ) @@ -32,17 +30,15 @@ HOME_LOCATION_NAME = "Home" ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.met_eireann_{HOME_LOCATION_NAME}" -_LOGGER = logging.getLogger(".") - FORECAST_MAP = { ATTR_FORECAST_CONDITION: "condition", - ATTR_FORECAST_PRESSURE: "pressure", + ATTR_FORECAST_NATIVE_PRESSURE: "pressure", ATTR_FORECAST_PRECIPITATION: "precipitation", - ATTR_FORECAST_TEMP: "temperature", - ATTR_FORECAST_TEMP_LOW: "templow", + ATTR_FORECAST_NATIVE_TEMP: "temperature", + ATTR_FORECAST_NATIVE_TEMP_LOW: "templow", ATTR_FORECAST_TIME: "datetime", ATTR_FORECAST_WIND_BEARING: "wind_bearing", - ATTR_FORECAST_WIND_SPEED: "wind_speed", + ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", } CONDITION_MAP = { diff --git a/homeassistant/components/met_eireann/translations/bg.json b/homeassistant/components/met_eireann/translations/bg.json new file mode 100644 index 00000000000..2c39cd06b7d --- /dev/null +++ b/homeassistant/components/met_eireann/translations/bg.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index cbf5c99342a..f20f0e1254a 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -3,8 +3,6 @@ import logging from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, WeatherEntity, ) @@ -13,12 +11,9 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - LENGTH_INCHES, LENGTH_MILLIMETERS, PRESSURE_HPA, - PRESSURE_INHG, SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -27,9 +22,6 @@ from homeassistant.helpers.entity 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 homeassistant.util.distance import convert as convert_distance -from homeassistant.util.pressure import convert as convert_pressure -from homeassistant.util.speed import convert as convert_speed from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP @@ -54,12 +46,8 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - MetEireannWeather( - coordinator, config_entry.data, hass.config.units.is_metric, False - ), - MetEireannWeather( - coordinator, config_entry.data, hass.config.units.is_metric, True - ), + MetEireannWeather(coordinator, config_entry.data, False), + MetEireannWeather(coordinator, config_entry.data, True), ] ) @@ -67,11 +55,15 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" - def __init__(self, coordinator, config, is_metric, hourly): + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + + def __init__(self, coordinator, config, hourly): """Initialise the platform with a data instance and site.""" super().__init__(coordinator) self._config = config - self._is_metric = is_metric self._hourly = hourly @property @@ -109,23 +101,14 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): ) @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data.current_weather_data.get("temperature") @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" - pressure_hpa = self.coordinator.data.current_weather_data.get("pressure") - if self._is_metric or pressure_hpa is None: - return pressure_hpa - - return round(convert_pressure(pressure_hpa, PRESSURE_HPA, PRESSURE_INHG), 2) + return self.coordinator.data.current_weather_data.get("pressure") @property def humidity(self): @@ -133,16 +116,9 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): return self.coordinator.data.current_weather_data.get("humidity") @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - speed_m_s = self.coordinator.data.current_weather_data.get("wind_speed") - if self._is_metric or speed_m_s is None: - return speed_m_s - - speed_mi_h = convert_speed( - speed_m_s, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR - ) - return int(round(speed_mi_h)) + return self.coordinator.data.current_weather_data.get("wind_speed") @property def wind_bearing(self): @@ -161,7 +137,7 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): me_forecast = self.coordinator.data.hourly_forecast else: me_forecast = self.coordinator.data.daily_forecast - required_keys = {ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME} + required_keys = {"temperature", "datetime"} ha_forecast = [] @@ -171,13 +147,6 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): ha_item = { k: item[v] for k, v in FORECAST_MAP.items() if item.get(v) is not None } - if not self._is_metric and ATTR_FORECAST_PRECIPITATION in ha_item: - precip_inches = convert_distance( - ha_item[ATTR_FORECAST_PRECIPITATION], - LENGTH_MILLIMETERS, - LENGTH_INCHES, - ) - ha_item[ATTR_FORECAST_PRECIPITATION] = round(precip_inches, 2) if ha_item.get(ATTR_FORECAST_CONDITION): ha_item[ATTR_FORECAST_CONDITION] = format_condition( ha_item[ATTR_FORECAST_CONDITION] diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index 26e2ac1bda2..d05c63ef684 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -1,11 +1,13 @@ """Config flow to configure the Meteo-France integration.""" +from __future__ import annotations + import logging from meteofrance_api.client import MeteoFranceClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.core import callback @@ -25,7 +27,9 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> MeteoFranceOptionsFlowHandler: """Get the options flow for this handler.""" return MeteoFranceOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index cca1f6fe684..a30a65304b0 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -4,16 +4,22 @@ import time from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODE, TEMP_CELSIUS +from homeassistant.const import ( + CONF_MODE, + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -71,6 +77,11 @@ async def async_setup_entry( class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + def __init__(self, coordinator: DataUpdateCoordinator, mode: str) -> None: """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) @@ -107,17 +118,12 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): ) @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data.current_forecast["T"]["value"] @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.coordinator.data.current_forecast["sea_level"] @@ -127,10 +133,9 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): return self.coordinator.data.current_forecast["humidity"] @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - # convert from API m/s to km/h - return round(self.coordinator.data.current_forecast["wind"]["speed"] * 3.6) + return self.coordinator.data.current_forecast["wind"]["speed"] @property def wind_bearing(self): @@ -158,9 +163,9 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): ATTR_FORECAST_CONDITION: format_condition( forecast["weather"]["desc"] ), - ATTR_FORECAST_TEMP: forecast["T"]["value"], - ATTR_FORECAST_PRECIPITATION: forecast["rain"].get("1h"), - ATTR_FORECAST_WIND_SPEED: forecast["wind"]["speed"], + ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["value"], + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast["rain"].get("1h"), + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast["wind"]["speed"], ATTR_FORECAST_WIND_BEARING: forecast["wind"]["direction"] if forecast["wind"]["direction"] != -1 else None, @@ -179,9 +184,11 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): ATTR_FORECAST_CONDITION: format_condition( forecast["weather12H"]["desc"] ), - ATTR_FORECAST_TEMP: forecast["T"]["max"], - ATTR_FORECAST_TEMP_LOW: forecast["T"]["min"], - ATTR_FORECAST_PRECIPITATION: forecast["precipitation"]["24h"], + ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"], + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast["T"]["min"], + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast["precipitation"][ + "24h" + ], } ) return forecast_data diff --git a/homeassistant/components/meteoclimatic/translations/es.json b/homeassistant/components/meteoclimatic/translations/es.json index 027f85c60df..1b6e29770cc 100644 --- a/homeassistant/components/meteoclimatic/translations/es.json +++ b/homeassistant/components/meteoclimatic/translations/es.json @@ -11,6 +11,9 @@ "user": { "data": { "code": "C\u00f3digo de la estaci\u00f3n" + }, + "data_description": { + "code": "Parece ESCAT4300000043206B" } } } diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 4faecdaa3ac..8044dd04aa8 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -3,7 +3,7 @@ from meteoclimatic import Condition from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -38,6 +38,10 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, coordinator: DataUpdateCoordinator) -> None: """Initialise the weather platform.""" super().__init__(coordinator) @@ -71,27 +75,22 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): return format_condition(self.coordinator.data["weather"].condition) @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data["weather"].temp_current - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def humidity(self): """Return the humidity.""" return self.coordinator.data["weather"].humidity_current @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.coordinator.data["weather"].pressure_current @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self.coordinator.data["weather"].wind_current diff --git a/homeassistant/components/metoffice/translations/sv.json b/homeassistant/components/metoffice/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/metoffice/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index daf37bcf83f..f4e0bf61d30 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -1,15 +1,15 @@ """Support for UK Met Office weather service.""" from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import PRESSURE_HPA, SPEED_MILES_PER_HOUR, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -27,8 +27,6 @@ from .const import ( MODE_3HOURLY_LABEL, MODE_DAILY, MODE_DAILY_LABEL, - VISIBILITY_CLASSES, - VISIBILITY_DISTANCE_CLASSES, ) @@ -55,11 +53,11 @@ def _build_forecast_data(timestep): if timestep.precipitation: data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = timestep.precipitation.value if timestep.temperature: - data[ATTR_FORECAST_TEMP] = timestep.temperature.value + data[ATTR_FORECAST_NATIVE_TEMP] = timestep.temperature.value if timestep.wind_direction: data[ATTR_FORECAST_WIND_BEARING] = timestep.wind_direction.value if timestep.wind_speed: - data[ATTR_FORECAST_WIND_SPEED] = timestep.wind_speed.value + data[ATTR_FORECAST_NATIVE_WIND_SPEED] = timestep.wind_speed.value return data @@ -73,6 +71,10 @@ def _get_weather_condition(metoffice_code): class MetOfficeWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Office weather condition.""" + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_wind_speed_unit = SPEED_MILES_PER_HOUR + def __init__(self, coordinator, hass_data, use_3hourly): """Initialise the platform with a data instance.""" super().__init__(coordinator) @@ -94,32 +96,14 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): return None @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" if self.coordinator.data.now.temperature: return self.coordinator.data.now.temperature.value return None @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def visibility(self): - """Return the platform visibility.""" - _visibility = None - weather_now = self.coordinator.data.now - if hasattr(weather_now, "visibility"): - visibility_class = VISIBILITY_CLASSES.get(weather_now.visibility.value) - visibility_distance = VISIBILITY_DISTANCE_CLASSES.get( - weather_now.visibility.value - ) - _visibility = f"{visibility_class} - {visibility_distance}" - return _visibility - - @property - def pressure(self): + def native_pressure(self): """Return the mean sea-level pressure.""" weather_now = self.coordinator.data.now if weather_now and weather_now.pressure: @@ -135,7 +119,7 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): return None @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" weather_now = self.coordinator.data.now if weather_now and weather_now.wind_speed: diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index 59902335d47..840b35c2f85 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -96,7 +96,7 @@ GENDERS = ["Female", "Male"] DEFAULT_LANG = "en-us" DEFAULT_GENDER = "Female" DEFAULT_TYPE = "JennyNeural" -DEFAULT_OUTPUT = "audio-16khz-128kbitrate-mono-mp3" +DEFAULT_OUTPUT = "audio-24khz-96kbitrate-mono-mp3" DEFAULT_RATE = 0 DEFAULT_VOLUME = 0 DEFAULT_PITCH = "default" diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 1ef250a3f4e..856495dc0f2 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,81 +1,27 @@ """The Mikrotik component.""" -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_MANUFACTURER, - CONF_ARP_PING, - CONF_DETECTION_TIME, - CONF_FORCE_DHCP, - DEFAULT_API_PORT, - DEFAULT_DETECTION_TIME, - DEFAULT_NAME, - DOMAIN, - PLATFORMS, -) -from .hub import MikrotikHub +from .const import ATTR_MANUFACTURER, DOMAIN, PLATFORMS +from .hub import MikrotikDataUpdateCoordinator -MIKROTIK_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, - vol.Optional(CONF_ARP_PING, default=False): cv.boolean, - vol.Optional(CONF_FORCE_DHCP, default=False): cv.boolean, - vol.Optional( - CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME - ): cv.time_period, - } - ) -) - - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), {DOMAIN: vol.All(cv.ensure_list, [MIKROTIK_SCHEMA])} - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Mikrotik component from config.""" - - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Mikrotik component.""" - hub = MikrotikHub(hass, config_entry) + hub = MikrotikDataUpdateCoordinator(hass, config_entry) if not await hub.async_setup(): return False + await hub.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub + + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 922df221d5a..36b65b6f2ba 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Mikrotik.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -32,7 +34,9 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MikrotikOptionsFlowHandler: """Get the options flow for this handler.""" return MikrotikOptionsFlowHandler(config_entry) @@ -74,19 +78,11 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config): - """Import Miktortik from config.""" - - import_config[CONF_DETECTION_TIME] = import_config[ - CONF_DETECTION_TIME - ].total_seconds() - return await self.async_step_user(user_input=import_config) - class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): """Handle Mikrotik options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Mikrotik options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 16c3ed233d8..9389d3bea5c 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -1,8 +1,6 @@ """Support for Mikrotik routers as device tracker.""" from __future__ import annotations -import logging - from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import ( DOMAIN as DEVICE_TRACKER, @@ -11,13 +9,12 @@ from homeassistant.components.device_tracker.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .hub import MikrotikDataUpdateCoordinator # These are normalized to ATTR_IP and ATTR_MAC to conform # to device_tracker @@ -32,7 +29,7 @@ async def async_setup_entry( """Set up device tracker for Mikrotik component.""" hub = hass.data[DOMAIN][config_entry.entry_id] - tracked: dict[str, MikrotikHubTracker] = {} + tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {} registry = entity_registry.async_get(hass) @@ -56,7 +53,7 @@ async def async_setup_entry( """Update the status of the device.""" update_items(hub, async_add_entities, tracked) - async_dispatcher_connect(hass, hub.signal_update, update_hub) + config_entry.async_on_unload(hub.async_add_listener(update_hub)) update_hub() @@ -67,21 +64,22 @@ def update_items(hub, async_add_entities, tracked): new_tracked = [] for mac, device in hub.api.devices.items(): if mac not in tracked: - tracked[mac] = MikrotikHubTracker(device, hub) + tracked[mac] = MikrotikDataUpdateCoordinatorTracker(device, hub) new_tracked.append(tracked[mac]) if new_tracked: async_add_entities(new_tracked) -class MikrotikHubTracker(ScannerEntity): +class MikrotikDataUpdateCoordinatorTracker(CoordinatorEntity, ScannerEntity): """Representation of network device.""" + coordinator: MikrotikDataUpdateCoordinator + def __init__(self, device, hub): """Initialize the tracked device.""" + super().__init__(hub) self.device = device - self.hub = hub - self.unsub_dispatcher = None @property def is_connected(self): @@ -89,7 +87,7 @@ class MikrotikHubTracker(ScannerEntity): if ( self.device.last_seen and (dt_util.utcnow() - self.device.last_seen) - < self.hub.option_detection_time + < self.coordinator.option_detection_time ): return True return False @@ -125,33 +123,9 @@ class MikrotikHubTracker(ScannerEntity): """Return a unique identifier for this device.""" return self.device.mac - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.hub.available - @property def extra_state_attributes(self): """Return the device state attributes.""" if self.is_connected: return {k: v for k, v in self.device.attrs.items() if k not in FILTER_ATTRS} return None - - async def async_added_to_hass(self): - """Client entity created.""" - _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, self.hub.signal_update, self.async_write_ha_state - ) - - async def async_update(self): - """Synchronize state with hub.""" - _LOGGER.debug( - "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id - ) - await self.hub.request_update() - - async def will_remove_from_hass(self): - """Disconnect from dispatcher.""" - if self.unsub_dispatcher: - self.unsub_dispatcher() diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 63be0a4a358..7f2314bd057 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -9,7 +9,7 @@ from librouteros.login import plain as login_plain, token as login_token from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -25,13 +25,13 @@ from .const import ( CONF_FORCE_DHCP, DEFAULT_DETECTION_TIME, DHCP, + DOMAIN, IDENTITY, INFO, IS_CAPSMAN, IS_WIRELESS, MIKROTIK_SERVICES, NAME, - PLATFORMS, WIRELESS, ) from .errors import CannotConnect, LoginError @@ -154,10 +154,8 @@ class MikrotikData: """Connect to hub.""" try: self.api = get_api(self.hass, self.config_entry.data) - self.available = True return True except (LoginError, CannotConnect): - self.available = False return False def get_list_from_interface(self, interface): @@ -194,9 +192,8 @@ class MikrotikData: # get new hub firmware version if updated self.firmware = self.get_info(ATTR_FIRMWARE) - except (CannotConnect, socket.timeout, OSError): - self.available = False - return + except (CannotConnect, socket.timeout, OSError) as err: + raise UpdateFailed from err if not device_list: return @@ -263,7 +260,8 @@ class MikrotikData: socket.timeout, ) as api_error: _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) - raise CannotConnect from api_error + if not self.connect_to_hub(): + raise CannotConnect from api_error except librouteros.exceptions.ProtocolError as api_error: _LOGGER.warning( "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", @@ -275,15 +273,8 @@ class MikrotikData: return response if response else None - def update(self): - """Update device_tracker from Mikrotik API.""" - if (not self.available or not self.api) and not self.connect_to_hub(): - return - _LOGGER.debug("updating network devices for host: %s", self._host) - self.update_devices() - -class MikrotikHub: +class MikrotikDataUpdateCoordinator(DataUpdateCoordinator): """Mikrotik Hub Object.""" def __init__(self, hass, config_entry): @@ -291,7 +282,13 @@ class MikrotikHub: self.hass = hass self.config_entry = config_entry self._mk_data = None - self.progress = None + super().__init__( + self.hass, + _LOGGER, + name=f"{DOMAIN} - {self.host}", + update_method=self.async_update, + update_interval=timedelta(seconds=10), + ) @property def host(self): @@ -328,11 +325,6 @@ class MikrotikHub: """Config entry option defining number of seconds from last seen to away.""" return timedelta(seconds=self.config_entry.options[CONF_DETECTION_TIME]) - @property - def signal_update(self): - """Event specific per Mikrotik entry to signal updates.""" - return f"mikrotik-update-{self.host}" - @property def api(self): """Represent Mikrotik data object.""" @@ -354,21 +346,9 @@ class MikrotikHub: self.config_entry, data=data, options=options ) - async def request_update(self): - """Request an update.""" - if self.progress is not None: - await self.progress - return - - self.progress = self.hass.async_create_task(self.async_update()) - await self.progress - - self.progress = None - async def async_update(self): """Update Mikrotik devices information.""" - await self.hass.async_add_executor_job(self._mk_data.update) - async_dispatcher_send(self.hass, self.signal_update) + await self.hass.async_add_executor_job(self._mk_data.update_devices) async def async_setup(self): """Set up the Mikrotik hub.""" @@ -384,9 +364,7 @@ class MikrotikHub: self._mk_data = MikrotikData(self.hass, self.config_entry, api) await self.async_add_options() await self.hass.async_add_executor_job(self._mk_data.get_hub_details) - await self.hass.async_add_executor_job(self._mk_data.update) - self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) return True diff --git a/homeassistant/components/mill/translations/sv.json b/homeassistant/components/mill/translations/sv.json new file mode 100644 index 00000000000..8cef92a32b9 --- /dev/null +++ b/homeassistant/components/mill/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "cloud": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "connection_type": "V\u00e4lj anslutningstyp" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 5c117357729..c5a51cdda7a 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -6,7 +6,11 @@ import statistics import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -157,6 +161,10 @@ def calc_median(sensor_values, round_digits): class MinMaxSensor(SensorEntity): """Representation of a min/max sensor.""" + _attr_icon = ICON + _attr_should_poll = False + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, entity_ids, name, sensor_type, round_digits, unique_id): """Initialize the min/max sensor.""" self._attr_unique_id = unique_id @@ -165,9 +173,9 @@ class MinMaxSensor(SensorEntity): self._round_digits = round_digits if name: - self._name = name + self._attr_name = name else: - self._name = f"{sensor_type} sensor".capitalize() + self._attr_name = f"{sensor_type} sensor".capitalize() self._sensor_attr = SENSOR_TYPE_TO_ATTR[self._sensor_type] self._unit_of_measurement = None self._unit_of_measurement_mismatch = False @@ -192,11 +200,6 @@ class MinMaxSensor(SensorEntity): self._calc_values() - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def native_value(self): """Return the state of the sensor.""" @@ -211,11 +214,6 @@ class MinMaxSensor(SensorEntity): return "ERR" return self._unit_of_measurement - @property - def should_poll(self): - """No polling needed.""" - return False - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" @@ -227,11 +225,6 @@ class MinMaxSensor(SensorEntity): return {ATTR_LAST_ENTITY_ID: self.last_entity_id} return None - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @callback def _async_min_max_sensor_state_listener(self, event, update_state=True): """Handle the sensor state changes.""" diff --git a/homeassistant/components/min_max/translations/es.json b/homeassistant/components/min_max/translations/es.json index aceaa287a3b..fadad650eaa 100644 --- a/homeassistant/components/min_max/translations/es.json +++ b/homeassistant/components/min_max/translations/es.json @@ -5,11 +5,13 @@ "data": { "entity_ids": "Entidades de entrada", "name": "Nombre", + "round_digits": "Precisi\u00f3n", "type": "Caracter\u00edstica estad\u00edstica" }, "data_description": { "round_digits": "Controla el n\u00famero de d\u00edgitos decimales cuando la caracter\u00edstica estad\u00edstica es la media o mediana." }, + "description": "Cree un sensor que calcule un valor m\u00ednimo, m\u00e1ximo, medio o mediano a partir de una lista de sensores de entrada.", "title": "Agregar sensor m\u00edn / m\u00e1x / media / mediana" } } diff --git a/homeassistant/components/min_max/translations/ja.json b/homeassistant/components/min_max/translations/ja.json index f9e767082d6..072e7a9b487 100644 --- a/homeassistant/components/min_max/translations/ja.json +++ b/homeassistant/components/min_max/translations/ja.json @@ -12,7 +12,7 @@ "round_digits": "\u7d71\u8a08\u7684\u7279\u6027\u304c\u5e73\u5747\u5024\u307e\u305f\u306f\u4e2d\u592e\u5024\u306e\u5834\u5408\u306b\u3001\u51fa\u529b\u306b\u542b\u307e\u308c\u308b\u5c0f\u6570\u70b9\u4ee5\u4e0b\u306e\u6841\u6570\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002" }, "description": "\u7d71\u8a08\u7684\u7279\u6027\u304c\u5e73\u5747\u307e\u305f\u306f\u4e2d\u592e\u5024\u306a\u5834\u5408\u306e\u7cbe\u5ea6\u3067\u3001\u5c0f\u6570\u70b9\u4ee5\u4e0b\u306e\u6841\u6570\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002", - "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024 \u30bb\u30f3\u30b5\u30fc\u3092\u8ffd\u52a0" + "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024\u30bb\u30f3\u30b5\u30fc\u3092\u8ffd\u52a0" } } }, @@ -30,5 +30,5 @@ } } }, - "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024 \u30bb\u30f3\u30b5\u30fc" + "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024\u30bb\u30f3\u30b5\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/mjpeg/translations/sv.json b/homeassistant/components/mjpeg/translations/sv.json new file mode 100644 index 00000000000..dd590fec4ba --- /dev/null +++ b/homeassistant/components/mjpeg/translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "error": { + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "init": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 545c3511fc9..e1c0841984f 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -13,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.entity import DeviceInfo -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSONEncoder, json_loads from .const import ( ATTR_APP_DATA, @@ -85,7 +85,7 @@ def _decrypt_payload_helper( key_bytes = get_key_bytes(key, keylen) msg_bytes = decrypt(ciphertext, key_bytes) - message = json.loads(msg_bytes.decode("utf-8")) + message = json_loads(msg_bytes) _LOGGER.debug("Successfully decrypted mobile_app payload") return message diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index e50eabb17aa..4f84abd4533 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -1,8 +1,6 @@ """A sensor for incoming calls using a USB modem that supports caller ID.""" from __future__ import annotations -import logging - from phone_modem import PhoneModem from homeassistant.components.sensor import SensorEntity @@ -13,8 +11,6 @@ from homeassistant.helpers import entity_platform from .const import CID, DATA_KEY_API, DOMAIN, ICON -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/modem_callerid/services.yaml b/homeassistant/components/modem_callerid/services.yaml deleted file mode 100644 index 7ec8aaf3f94..00000000000 --- a/homeassistant/components/modem_callerid/services.yaml +++ /dev/null @@ -1,7 +0,0 @@ -reject_call: - name: Reject Call - description: Reject incoming call. - target: - entity: - integration: modem_callerid - domain: sensor diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index af4f05a1536..ed4212d9444 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -74,12 +74,12 @@ def modernforms_exception_handler(func): async def handler(self, *args, **kwargs): try: await func(self, *args, **kwargs) - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ModernFormsConnectionError as error: _LOGGER.error("Error communicating with API: %s", error) self.coordinator.last_update_success = False - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ModernFormsError as error: _LOGGER.error("Invalid response from API: %s", error) @@ -108,11 +108,6 @@ class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceSt update_interval=SCAN_INTERVAL, ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() - async def _async_update_data(self) -> ModernFormsDevice: """Fetch data from Modern Forms.""" try: diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 318bb8c969d..8bd8665dc3b 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -127,7 +127,7 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): async def async_turn_on( self, percentage: int | None = None, - preset_mode: int | None = None, + preset_mode: str | None = None, **kwargs: Any, ) -> None: """Turn on the fan.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 86306a56033..64bdfeb4e6d 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -83,8 +83,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): async def async_set_cooling(self, enabled: bool) -> None: """Enable or disable cooling mode.""" await self.base.set_cooling(enabled) - for update_callback in self._listeners: - update_callback() + self.async_update_listeners() async def async_set_target_temperature( self, heat_area_id: str, target_temperature: float @@ -117,8 +116,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): "Failed to set target temperature, communication error with alpha2 base" ) from http_err self.data["heat_areas"][heat_area_id].update(update_data) - for update_callback in self._listeners: - update_callback() + self.async_update_listeners() async def async_set_heat_area_mode( self, heat_area_id: str, heat_area_mode: int @@ -161,5 +159,5 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ "heat_areas" ][heat_area_id]["T_HEAT_NIGHT"] - for update_callback in self._listeners: - update_callback() + + self.async_update_listeners() diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index 1261832c371..4065b003ba3 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Monoprice 6-Zone Amplifier integration.""" +from __future__ import annotations + import logging from pymonoprice import get_async_monoprice @@ -90,7 +92,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MonopriceOptionsFlowHandler: """Define the config flow to handle options.""" return MonopriceOptionsFlowHandler(config_entry) @@ -110,7 +114,7 @@ def _key_for_source(index, source, previous_sources): class MonopriceOptionsFlowHandler(config_entries.OptionsFlow): """Handle a Monoprice options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index e40f22296cb..d861c989ee0 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -1,4 +1,8 @@ """Config flow to configure Motion Blinds using their WLAN API.""" +from __future__ import annotations + +from typing import Any + from motionblinds import MotionDiscovery import voluptuous as vol @@ -33,9 +37,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -60,15 +66,17 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the Motion Blinds flow.""" - self._host = None - self._ips = [] + self._host: str | None = None + self._ips: list[str] = [] self._config_settings = None @staticmethod @callback - def async_get_options_flow(config_entry) -> OptionsFlowHandler: + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler(config_entry) @@ -87,7 +95,9 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = discovery_info.ip return await self.async_step_connect() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: @@ -114,7 +124,9 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) - async def async_step_select(self, user_input=None): + async def async_step_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle multiple motion gateways found.""" if user_input is not None: self._host = user_input["select_ip"] @@ -124,9 +136,11 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="select", data_schema=select_schema) - async def async_step_connect(self, user_input=None): + async def async_step_connect( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Connect to the Motion Gateway.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: key = user_input[CONF_API_KEY] diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 7bac3a5fb20..a73166912f4 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -1,5 +1,8 @@ """Support for Motion Blinds using their WLAN API.""" +from __future__ import annotations + import logging +from typing import Any from motionblinds import DEVICE_TYPES_WIFI, BlindType import voluptuous as vol @@ -35,6 +38,7 @@ from .const import ( SERVICE_SET_ABSOLUTE_POSITION, UPDATE_INTERVAL_MOVING, ) +from .gateway import device_name _LOGGER = logging.getLogger(__name__) @@ -193,13 +197,12 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): if blind.device_type in DEVICE_TYPES_WIFI: via_device = () connections = {(dr.CONNECTION_NETWORK_MAC, blind.mac)} - name = blind.blind_type else: via_device = (DOMAIN, blind._gateway.mac) connections = {} - name = f"{blind.blind_type} {blind.mac[12:]}" sw_version = None + name = device_name(blind) self._attr_device_class = device_class self._attr_name = name self._attr_unique_id = blind.mac @@ -215,7 +218,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): ) @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" if self.coordinator.data is None: return False @@ -226,7 +229,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """ Return current position of cover. @@ -237,18 +240,18 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): return 100 - self._blind.position @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._blind.position is None: return None return self._blind.position == 100 - async def async_added_to_hass(self): + 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): + 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() @@ -288,19 +291,19 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): self.hass, UPDATE_INTERVAL_MOVING, self.async_scheduled_update_request ) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Open) await self.async_request_position_till_stop() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Close) await self.async_request_position_till_stop() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] async with self._api_lock: @@ -327,7 +330,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): ) await self.async_request_position_till_stop() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) @@ -339,7 +342,7 @@ class MotionTiltDevice(MotionPositionDevice): _restore_tilt = True @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """ Return current angle of cover. @@ -349,23 +352,23 @@ class MotionTiltDevice(MotionPositionDevice): return None return self._blind.angle * 100 / 180 - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, 180) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, 0) - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" angle = kwargs[ATTR_TILT_POSITION] * 180 / 100 async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, angle) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) @@ -377,7 +380,7 @@ class MotionTiltOnlyDevice(MotionTiltDevice): _restore_tilt = False @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = ( CoverEntityFeature.OPEN_TILT @@ -391,12 +394,12 @@ class MotionTiltOnlyDevice(MotionTiltDevice): return supported_features @property - def current_cover_position(self): + def current_cover_position(self) -> None: """Return current position of cover.""" return None @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._blind.angle is None: return None @@ -422,14 +425,14 @@ class MotionTDBUDevice(MotionPositionDevice): super().__init__(coordinator, blind, device_class, sw_version) self._motor = motor self._motor_key = motor[0] - self._attr_name = f"{blind.blind_type} {blind.mac[12:]} {motor}" + self._attr_name = f"{device_name(blind)} {motor}" self._attr_unique_id = f"{blind.mac}-{motor}" if self._motor not in ["Bottom", "Top", "Combined"]: _LOGGER.error("Unknown motor '%s'", self._motor) @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """ Return current position of cover. @@ -441,7 +444,7 @@ class MotionTDBUDevice(MotionPositionDevice): return 100 - self._blind.scaled_position[self._motor_key] @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._blind.position is None: return None @@ -452,7 +455,7 @@ class MotionTDBUDevice(MotionPositionDevice): return self._blind.position[self._motor_key] == 100 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" attributes = {} if self._blind.position is not None: @@ -463,19 +466,19 @@ class MotionTDBUDevice(MotionPositionDevice): attributes[ATTR_WIDTH] = self._blind.width return attributes - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Open, self._motor_key) await self.async_request_position_till_stop() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Close, self._motor_key) await self.async_request_position_till_stop() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific scaled position.""" position = kwargs[ATTR_POSITION] async with self._api_lock: @@ -496,7 +499,7 @@ class MotionTDBUDevice(MotionPositionDevice): await self.async_request_position_till_stop() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop, self._motor_key) diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 218da9f625c..96a85246666 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -3,7 +3,7 @@ import contextlib import logging import socket -from motionblinds import AsyncMotionMulticast, MotionGateway +from motionblinds import DEVICE_TYPES_WIFI, AsyncMotionMulticast, MotionGateway from homeassistant.components import network @@ -12,6 +12,13 @@ from .const import DEFAULT_INTERFACE _LOGGER = logging.getLogger(__name__) +def device_name(blind): + """Construct common name part of a device.""" + if blind.device_type in DEVICE_TYPES_WIFI: + return blind.blind_type + return f"{blind.blind_type} {blind.mac[12:]}" + + class ConnectMotionGateway: """Class to async connect to a Motion Gateway.""" diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index ebaed95bcbf..3a6f775092e 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -10,6 +10,7 @@ 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 ATTR_BATTERY_VOLTAGE = "battery_voltage" TYPE_BLIND = "blind" @@ -54,14 +55,9 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): """Initialize the Motion Battery Sensor.""" super().__init__(coordinator) - if blind.device_type in DEVICE_TYPES_WIFI: - name = f"{blind.blind_type} battery" - else: - name = f"{blind.blind_type} {blind.mac[12:]} battery" - self._blind = blind self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, blind.mac)}) - self._attr_name = name + self._attr_name = f"{device_name(blind)} battery" self._attr_unique_id = f"{blind.mac}-battery" @property @@ -103,14 +99,9 @@ class MotionTDBUBatterySensor(MotionBatterySensor): """Initialize the Motion Battery Sensor.""" super().__init__(coordinator, blind) - if blind.device_type in DEVICE_TYPES_WIFI: - name = f"{blind.blind_type} {motor} battery" - else: - name = f"{blind.blind_type} {blind.mac[12:]} {motor} battery" - self._motor = motor self._attr_unique_id = f"{blind.mac}-{motor}-battery" - self._attr_name = name + self._attr_name = f"{device_name(blind)} {motor} battery" @property def native_value(self): @@ -144,10 +135,8 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): if device_type == TYPE_GATEWAY: name = "Motion gateway signal strength" - elif device.device_type in DEVICE_TYPES_WIFI: - name = f"{device.blind_type} signal strength" else: - name = f"{device.blind_type} {device.mac[12:]} signal strength" + name = f"{device_name(device)} signal strength" self._device = device self._device_type = device_type diff --git a/homeassistant/components/motion_blinds/translations/bg.json b/homeassistant/components/motion_blinds/translations/bg.json index e78d7032040..1629a8d981c 100644 --- a/homeassistant/components/motion_blinds/translations/bg.json +++ b/homeassistant/components/motion_blinds/translations/bg.json @@ -4,6 +4,7 @@ "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, + "flow_title": "{short_mac} ({ip_address})", "step": { "connect": { "data": { diff --git a/homeassistant/components/motion_blinds/translations/pt-BR.json b/homeassistant/components/motion_blinds/translations/pt-BR.json index 0a3b68357ee..eb0464e24a9 100644 --- a/homeassistant/components/motion_blinds/translations/pt-BR.json +++ b/homeassistant/components/motion_blinds/translations/pt-BR.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "connection_error": "Falha ao conectar" }, diff --git a/homeassistant/components/motion_blinds/translations/sv.json b/homeassistant/components/motion_blinds/translations/sv.json new file mode 100644 index 00000000000..eee10b4c765 --- /dev/null +++ b/homeassistant/components/motion_blinds/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "step": { + "connect": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 0361f4562c4..662c4d23660 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -1,6 +1,7 @@ """Config flow for motionEye integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any, cast from motioneye_client.client import ( @@ -156,12 +157,9 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) - async def async_step_reauth( - self, - config_data: dict[str, Any] | None = None, - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthentication flow.""" - return await self.async_step_user(config_data) + return await self.async_step_user() async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle Supervisor discovery.""" diff --git a/homeassistant/components/motioneye/translations/bg.json b/homeassistant/components/motioneye/translations/bg.json new file mode 100644 index 00000000000..ef56ca3f2df --- /dev/null +++ b/homeassistant/components/motioneye/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "admin_password": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0441\u043a\u0430 \u043f\u0430\u0440\u043e\u043b\u0430", + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/sv.json b/homeassistant/components/motioneye/translations/sv.json new file mode 100644 index 00000000000..8fd6e00680b --- /dev/null +++ b/homeassistant/components/motioneye/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "admin_username": "Admin Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index fc4e15f3237..a099e7b580c 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -28,14 +28,12 @@ from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.reload import ( async_integration_yaml_config, - async_setup_reload_service, + async_reload_integration_platforms, ) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow @@ -78,10 +76,10 @@ from .const import ( # noqa: F401 DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, - MQTT_RELOADED, PLATFORMS, RELOADABLE_PLATFORMS, ) +from .mixins import async_discover_yaml_entities from .models import ( # noqa: F401 MqttCommandTemplate, MqttValueTemplate, @@ -380,45 +378,67 @@ async def async_setup_entry( # noqa: C901 hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() hass.data[CONFIG_ENTRY_IS_SETUP] = set() - # Setup reload service. Once support for legacy config is removed in 2022.9, we - # should no longer call async_setup_reload_service but instead implement a custom - # service - await async_setup_reload_service(hass, DOMAIN, RELOADABLE_PLATFORMS) + async def async_setup_reload_service() -> None: + """Create the reload service for the MQTT domain.""" + if hass.services.has_service(DOMAIN, SERVICE_RELOAD): + return - async def _async_reload_platforms(_: Event | None) -> None: - """Discover entities for a platform.""" - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} - hass.data[DATA_MQTT_UPDATED_CONFIG] = config_yaml.get(DOMAIN, {}) - async_dispatcher_send(hass, MQTT_RELOADED) + async def _reload_config(call: ServiceCall) -> None: + """Reload the platforms.""" + # Reload the legacy yaml platform + await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS) - async def async_forward_entry_setup(): - """Forward the config entry setup to the platforms.""" - async with hass.data[DATA_CONFIG_ENTRY_LOCK]: - for component in PLATFORMS: - config_entries_key = f"{component}.mqtt" - if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: - hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) - await hass.config_entries.async_forward_entry_setup( - entry, component - ) + # Reload the modern yaml platforms + config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + hass.data[DATA_MQTT_UPDATED_CONFIG] = config_yaml.get(DOMAIN, {}) + await asyncio.gather( + *( + [ + async_discover_yaml_entities(hass, component) + for component in RELOADABLE_PLATFORMS + ] + ) + ) + + # Fire event + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + + async def async_forward_entry_setup_and_setup_discovery(config_entry): + """Forward the config entry setup to the platforms and set up discovery.""" + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from . import device_automation, tag + + await asyncio.gather( + *( + [ + device_automation.async_setup_entry(hass, config_entry), + tag.async_setup_entry(hass, config_entry), + ] + + [ + hass.config_entries.async_forward_entry_setup(entry, component) + for component in PLATFORMS + ] + ) + ) + # Setup discovery + if conf.get(CONF_DISCOVERY): + await _async_setup_discovery(hass, conf, entry) # Setup reload service after all platforms have loaded - entry.async_on_unload( - hass.bus.async_listen("event_mqtt_reloaded", _async_reload_platforms) - ) + await async_setup_reload_service() - hass.async_create_task(async_forward_entry_setup()) + if DATA_MQTT_RELOAD_NEEDED in hass.data: + hass.data.pop(DATA_MQTT_RELOAD_NEEDED) + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=False, + ) - if conf.get(CONF_DISCOVERY): - await _async_setup_discovery(hass, conf, entry) - - if DATA_MQTT_RELOAD_NEEDED in hass.data: - hass.data.pop(DATA_MQTT_RELOAD_NEEDED) - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=False, - ) + hass.async_create_task(async_forward_entry_setup_and_setup_discovery(entry)) return True diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 6bb7d9cd0d1..b6f2f8f236e 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -44,8 +44,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -147,9 +147,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT alarm control panel through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, alarm.DOMAIN) - ) + await async_discover_yaml_entities(hass, alarm.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry @@ -172,7 +170,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): def __init__(self, hass, config, config_entry, discovery_data): """Init the MQTT Alarm Control Panel.""" - self._state = None + self._state: str | None = None MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @@ -233,7 +231,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self._state @@ -250,7 +248,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): ) @property - def code_format(self): + def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" if (code := self._config.get(CONF_CODE)) is None: return None @@ -259,12 +257,11 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): return alarm.CodeFormat.TEXT @property - def code_arm_required(self): + def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" - code_required = self._config.get(CONF_CODE_ARM_REQUIRED) - return code_required + return self._config[CONF_CODE_ARM_REQUIRED] - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. This method is a coroutine. @@ -275,7 +272,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): payload = self._config[CONF_PAYLOAD_DISARM] await self._publish(code, payload) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command. This method is a coroutine. @@ -286,7 +283,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_HOME] await self._publish(code, action) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command. This method is a coroutine. @@ -297,7 +294,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_AWAY] await self._publish(code, action) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command. This method is a coroutine. @@ -308,7 +305,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_NIGHT] await self._publish(code, action) - async def async_alarm_arm_vacation(self, code=None): + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command. This method is a coroutine. @@ -319,7 +316,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_VACATION] await self._publish(code, action) - async def async_alarm_arm_custom_bypass(self, code=None): + async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command. This method is a coroutine. @@ -330,7 +327,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_CUSTOM_BYPASS] await self._publish(code, action) - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Send trigger command. This method is a coroutine. diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 39fd87c8b02..9e0a049b15e 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -41,8 +41,8 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -102,9 +102,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT binary sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, binary_sensor.DOMAIN) - ) + await async_discover_yaml_entities(hass, binary_sensor.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 370243c3579..b75fbe4b97f 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -25,8 +25,8 @@ from .const import ( from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -82,9 +82,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT button through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, button.DOMAIN) - ) + await async_discover_yaml_entities(hass, button.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry @@ -130,7 +128,7 @@ class MqttButton(MqttEntity, ButtonEntity): """Return the device class of the sensor.""" return self._config.get(CONF_DEVICE_CLASS) - async def async_press(self, **kwargs): + async def async_press(self) -> None: """Turn the device on. This method is a coroutine. diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 5c8d3bc48b2..69af7992229 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -22,8 +22,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -80,9 +80,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT camera through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, camera.DOMAIN) - ) + await async_discover_yaml_entities(hass, camera.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 66699372516..d676c128260 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from functools import lru_cache, partial, wraps import inspect from itertools import groupby @@ -430,7 +430,7 @@ class MQTT: # Only subscribe if currently connected. if self.connected: self._last_subscribe = time.time() - await self._async_perform_subscription(topic, qos) + await self._async_perform_subscriptions(((topic, qos),)) @callback def async_remove() -> None: @@ -464,16 +464,37 @@ class MQTT: _raise_on_error(result) await self._wait_for_mid(mid) - async def _async_perform_subscription(self, topic: str, qos: int) -> None: - """Perform a paho-mqtt subscription.""" + async def _async_perform_subscriptions( + self, subscriptions: Iterable[tuple[str, int]] + ) -> None: + """Perform MQTT client subscriptions.""" + + def _process_client_subscriptions() -> list[tuple[int, int]]: + """Initiate all subscriptions on the MQTT client and return the results.""" + subscribe_result_list = [] + for topic, qos in subscriptions: + result, mid = self._mqttc.subscribe(topic, qos) + subscribe_result_list.append((result, mid)) + _LOGGER.debug("Subscribing to %s, mid: %s", topic, mid) + return subscribe_result_list + async with self._paho_lock: - result: int | None = None - result, mid = await self.hass.async_add_executor_job( - self._mqttc.subscribe, topic, qos + results = await self.hass.async_add_executor_job( + _process_client_subscriptions ) - _LOGGER.debug("Subscribing to %s, mid: %s", topic, mid) - _raise_on_error(result) - await self._wait_for_mid(mid) + + tasks = [] + errors = [] + for result, mid in results: + if result == 0: + tasks.append(self._wait_for_mid(mid)) + else: + errors.append(result) + + if tasks: + await asyncio.gather(*tasks) + if errors: + _raise_on_errors(errors) def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None: """On connect callback. @@ -502,10 +523,16 @@ class MQTT: # Group subscriptions to only re-subscribe once for each topic. keyfunc = attrgetter("topic") - for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), keyfunc): - # Re-subscribe with the highest requested qos - max_qos = max(subscription.qos for subscription in subs) - self.hass.add_job(self._async_perform_subscription, topic, max_qos) + self.hass.add_job( + self._async_perform_subscriptions, + [ + # Re-subscribe with the highest requested qos + (topic, max(subscription.qos for subscription in subs)) + for topic, subs in groupby( + sorted(self.subscriptions, key=keyfunc), keyfunc + ) + ], + ) if ( CONF_BIRTH_MESSAGE in self.conf @@ -638,15 +665,22 @@ class MQTT: ) -def _raise_on_error(result_code: int | None) -> None: +def _raise_on_errors(result_codes: Iterable[int | None]) -> None: """Raise error if error result.""" # pylint: disable-next=import-outside-toplevel import paho.mqtt.client as mqtt - if result_code is not None and result_code != 0: - raise HomeAssistantError( - f"Error talking to MQTT: {mqtt.error_string(result_code)}" - ) + if messages := [ + mqtt.error_string(result_code) + for result_code in result_codes + if result_code != 0 + ]: + raise HomeAssistantError(f"Error talking to MQTT: {', '.join(messages)}") + + +def _raise_on_error(result_code: int | None) -> None: + """Raise error if error result.""" + _raise_on_errors((result_code,)) def _matcher_for_topic(subscription: str) -> Any: diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index a26e9cba8df..6b09891483c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -50,8 +50,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -391,9 +391,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT climate device through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, climate.DOMAIN) - ) + await async_discover_yaml_entities(hass, climate.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 7137b93118f..a8d0957921d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1,4 +1,6 @@ """Config flow for MQTT.""" +from __future__ import annotations + from collections import OrderedDict import queue @@ -15,6 +17,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .client import MqttClientSetup @@ -45,7 +48,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _hassio_discovery = None @staticmethod - def async_get_options_flow(config_entry): + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MQTTOptionsFlowHandler: """Get the options flow for this handler.""" return MQTTOptionsFlowHandler(config_entry) @@ -120,7 +126,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_PORT: data[CONF_PORT], CONF_USERNAME: data.get(CONF_USERNAME), CONF_PASSWORD: data.get(CONF_PASSWORD), - CONF_PROTOCOL: data.get(CONF_PROTOCOL), CONF_DISCOVERY: DEFAULT_DISCOVERY, }, ) @@ -137,10 +142,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class MQTTOptionsFlowHandler(config_entries.OptionsFlow): """Handle MQTT options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize MQTT options flow.""" self.config_entry = config_entry - self.broker_config = {} + self.broker_config: dict[str, str | int] = {} self.options = dict(config_entry.options) async def async_step_init(self, user_input=None): diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 67a9208faba..6ac77021337 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -64,7 +64,6 @@ DOMAIN = "mqtt" MQTT_CONNECTED = "mqtt_connected" MQTT_DISCONNECTED = "mqtt_disconnected" -MQTT_RELOADED = "mqtt_reloaded" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" @@ -105,8 +104,8 @@ RELOADABLE_PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.NUMBER, - Platform.SELECT, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 0901a4f63a6..14746329250 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -2,8 +2,8 @@ from __future__ import annotations import functools -from json import JSONDecodeError, loads as json_loads import logging +from typing import Any import voluptuous as vol @@ -12,6 +12,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, + CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -29,6 +30,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -44,8 +46,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -240,9 +242,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT cover through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, cover.DOMAIN) - ) + await async_discover_yaml_entities(hass, cover.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry @@ -418,7 +418,7 @@ class MqttCover(MqttEntity, CoverEntity): try: payload = json_loads(payload) - except JSONDecodeError: + except JSON_DECODE_EXCEPTIONS: pass if isinstance(payload, dict): @@ -485,12 +485,12 @@ class MqttCover(MqttEntity, CoverEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" - return self._optimistic + return bool(self._optimistic) @property - def is_closed(self): + 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 @@ -498,17 +498,17 @@ class MqttCover(MqttEntity, CoverEntity): return self._state == STATE_CLOSED @property - def is_opening(self): + def is_opening(self) -> bool: """Return true if the cover is actively opening.""" return self._state == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return true if the cover is actively closing.""" return self._state == STATE_CLOSING @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -516,17 +516,17 @@ class MqttCover(MqttEntity, CoverEntity): return self._position @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt.""" return self._tilt_value @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Return the class of this sensor.""" return self._config.get(CONF_DEVICE_CLASS) @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = 0 if self._config.get(CONF_COMMAND_TOPIC) is not None: @@ -545,7 +545,7 @@ class MqttCover(MqttEntity, CoverEntity): return supported_features - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. This method is a coroutine. @@ -566,7 +566,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down. This method is a coroutine. @@ -587,7 +587,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device. This method is a coroutine. @@ -600,7 +600,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover open.""" tilt_open_position = self._config[CONF_TILT_OPEN_POSITION] variables = { @@ -625,7 +625,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover closed.""" tilt_closed_position = self._config[CONF_TILT_CLOSED_POSITION] variables = { @@ -652,7 +652,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt = kwargs[ATTR_TILT_POSITION] percentage_tilt = tilt @@ -680,7 +680,7 @@ class MqttCover(MqttEntity, CoverEntity): self._tilt_value = percentage_tilt self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] percentage_position = position @@ -711,7 +711,7 @@ class MqttCover(MqttEntity, CoverEntity): self._position = percentage_position self.async_write_ha_state() - async def async_toggle_tilt(self, **kwargs): + async def async_toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" if self.is_tilt_closed(): await self.async_open_cover_tilt(**kwargs) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 8685c790fd2..5b39e8fa1b5 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -4,19 +4,19 @@ from __future__ import annotations import asyncio from collections import deque import functools -import json import logging import re import time from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import RESULT_TYPE_ABORT +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.json import json_loads from homeassistant.loader import async_get_mqtt from .. import mqtt @@ -27,8 +27,6 @@ from .const import ( ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, CONF_TOPIC, - CONFIG_ENTRY_IS_SETUP, - DATA_CONFIG_ENTRY_LOCK, DOMAIN, ) @@ -119,7 +117,7 @@ async def async_start( # noqa: C901 if payload: try: - payload = json.loads(payload) + payload = json_loads(payload) except ValueError: _LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload) return @@ -227,28 +225,6 @@ async def async_start( # noqa: C901 # Add component _LOGGER.info("Found new component: %s %s", component, discovery_id) hass.data[ALREADY_DISCOVERED][discovery_hash] = None - - config_entries_key = f"{component}.mqtt" - async with hass.data[DATA_CONFIG_ENTRY_LOCK]: - if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: - if component == "device_automation": - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import device_automation - - await device_automation.async_setup_entry(hass, config_entry) - elif component == "tag": - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import tag - - await tag.async_setup_entry(hass, config_entry) - else: - await hass.config_entries.async_forward_entry_setup( - config_entry, component - ) - hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) - async_dispatcher_send( hass, MQTT_DISCOVERY_NEW.format(component, "mqtt"), payload ) @@ -305,7 +281,7 @@ async def async_start( # noqa: C901 ) if ( result - and result["type"] == RESULT_TYPE_ABORT + and result["type"] == FlowResultType.ABORT and result["reason"] in ("already_configured", "single_instance_allowed") ): diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 4e1d1465ac2..15e4a80f3e7 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -4,6 +4,7 @@ from __future__ import annotations import functools import logging import math +from typing import Any import voluptuous as vol @@ -49,8 +50,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -231,7 +232,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT fan through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload(await async_setup_platform_discovery(hass, fan.DOMAIN)) + await async_discover_yaml_entities(hass, fan.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry @@ -501,7 +502,7 @@ class MqttFan(MqttEntity, FanEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" return self._optimistic @@ -511,17 +512,17 @@ class MqttFan(MqttEntity, FanEntity): return self._state @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage.""" return self._percentage @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset _mode.""" return self._preset_mode @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return self._preset_modes @@ -536,16 +537,16 @@ class MqttFan(MqttEntity, FanEntity): return self._speed_count @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return the oscillation state.""" return self._oscillation # The speed attribute deprecated in the schema, support will be removed after a quarter (2021.7) async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the entity. @@ -567,7 +568,7 @@ class MqttFan(MqttEntity, FanEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the entity. This method is a coroutine. diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index d2856767cf0..5f09fc0d513 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -45,8 +45,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -187,9 +187,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT humidifier through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, humidifier.DOMAIN) - ) + await async_discover_yaml_entities(hass, humidifier.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index d4914cb9506..c7f3395ba4e 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -13,8 +13,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from ..mixins import ( + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -111,9 +111,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT lights configured under the light platform key (deprecated).""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, light.DOMAIN) - ) + await async_discover_yaml_entities(hass, light.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index be49f1ad2e3..716366cbe22 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -1,6 +1,5 @@ """Support for MQTT JSON lights.""" from contextlib import suppress -import json import logging import voluptuous as vol @@ -46,6 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import json_dumps, json_loads from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util @@ -317,7 +317,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): @log_messages(self.hass, self.entity_id) def state_received(msg): """Handle new MQTT messages.""" - values = json.loads(msg.payload) + values = json_loads(msg.payload) if values["state"] == "ON": self._state = True @@ -644,7 +644,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): await self.async_publish( self._topic[CONF_COMMAND_TOPIC], - json.dumps(message), + json_dumps(message), self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], @@ -669,7 +669,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): await self.async_publish( self._topic[CONF_COMMAND_TOPIC], - json.dumps(message), + json_dumps(message), self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index ab9eca9aafa..b4788f1db0c 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import functools +from typing import Any import voluptuous as vol @@ -27,8 +28,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -102,9 +103,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT lock through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, lock.DOMAIN) - ) + await async_discover_yaml_entities(hass, lock.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry @@ -183,21 +182,21 @@ class MqttLock(MqttEntity, LockEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" return self._optimistic @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return LockEntityFeature.OPEN if CONF_PAYLOAD_OPEN in self._config else 0 - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device. This method is a coroutine. @@ -214,7 +213,7 @@ class MqttLock(MqttEntity, LockEntity): self._state = True self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device. This method is a coroutine. @@ -231,7 +230,7 @@ class MqttLock(MqttEntity, LockEntity): self._state = False self.async_write_ha_state() - async def async_open(self, **kwargs): + async def async_open(self, **kwargs: Any) -> None: """Open the door latch. This method is a coroutine. diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index dcf387eb360..8e59d09dfce 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,7 +4,6 @@ from __future__ import annotations from abc import abstractmethod import asyncio from collections.abc import Callable -import json import logging from typing import Any, Protocol, cast, final @@ -27,7 +26,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -47,6 +46,7 @@ from homeassistant.helpers.entity import ( async_generate_entity_id, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import json_loads from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import debug_info, subscription @@ -69,7 +69,6 @@ from .const import ( DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, - MQTT_RELOADED, ) from .debug_info import log_message, log_messages from .discovery import ( @@ -261,34 +260,27 @@ class SetupEntity(Protocol): """Define setup_entities type.""" -async def async_setup_platform_discovery( +async def async_discover_yaml_entities( hass: HomeAssistant, platform_domain: str -) -> CALLBACK_TYPE: - """Set up platform discovery for manual config.""" - - async def _async_discover_entities() -> None: - """Discover entities for a platform.""" - if DATA_MQTT_UPDATED_CONFIG in hass.data: - # The platform has been reloaded - config_yaml = hass.data[DATA_MQTT_UPDATED_CONFIG] - else: - config_yaml = hass.data.get(DATA_MQTT_CONFIG, {}) - if not config_yaml: - return - if platform_domain not in config_yaml: - return - await asyncio.gather( - *( - discovery.async_load_platform(hass, platform_domain, DOMAIN, config, {}) - for config in await async_get_platform_config_from_yaml( - hass, platform_domain, config_yaml - ) +) -> None: + """Discover entities for a platform.""" + if DATA_MQTT_UPDATED_CONFIG in hass.data: + # The platform has been reloaded + config_yaml = hass.data[DATA_MQTT_UPDATED_CONFIG] + else: + config_yaml = hass.data.get(DATA_MQTT_CONFIG, {}) + if not config_yaml: + return + if platform_domain not in config_yaml: + return + await asyncio.gather( + *( + discovery.async_load_platform(hass, platform_domain, DOMAIN, config, {}) + for config in await async_get_platform_config_from_yaml( + hass, platform_domain, config_yaml ) ) - - unsub = async_dispatcher_connect(hass, MQTT_RELOADED, _async_discover_entities) - await _async_discover_entities() - return unsub + ) async def async_get_platform_config_from_yaml( @@ -393,7 +385,7 @@ class MqttAttributes(Entity): def attributes_message_received(msg: ReceiveMessage) -> None: try: payload = attr_tpl(msg.payload) - json_dict = json.loads(payload) if isinstance(payload, str) else None + json_dict = json_loads(payload) if isinstance(payload, str) else None if isinstance(json_dict, dict): filtered_dict = { k: v diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 7ec07394aa5..dc27a740720 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -11,10 +11,12 @@ from homeassistant.components.number import ( DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, - NumberEntity, + DEVICE_CLASSES_SCHEMA, + RestoreNumber, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_OPTIMISTIC, CONF_UNIT_OF_MEASUREMENT, @@ -23,7 +25,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -40,8 +41,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -78,6 +79,7 @@ def validate_config(config): _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): vol.Coerce(float), vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -133,9 +135,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT number through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, number.DOMAIN) - ) + await async_discover_yaml_entities(hass, number.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry @@ -150,7 +150,7 @@ async def _async_setup_entity( async_add_entities([MqttNumber(hass, config, config_entry, discovery_data)]) -class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): +class MqttNumber(MqttEntity, RestoreNumber): """representation of an MQTT number.""" _entity_id_format = number.ENTITY_ID_FORMAT @@ -164,7 +164,7 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): self._current_number = None - NumberEntity.__init__(self) + RestoreNumber.__init__(self) MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod @@ -241,35 +241,37 @@ class MqttNumber(MqttEntity, NumberEntity, 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()): - self._current_number = last_state.state + if self._optimistic and ( + last_number_data := await self.async_get_last_number_data() + ): + self._current_number = last_number_data.native_value @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return self._config[CONF_MIN] @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return self._config[CONF_MAX] @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return self._config[CONF_STEP] @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return self._config.get(CONF_UNIT_OF_MEASUREMENT) @property - def value(self): + def native_value(self): """Return the current value.""" return self._current_number - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" current_number = value @@ -293,3 +295,8 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): def assumed_state(self): """Return true if we do optimistic updates.""" return self._optimistic + + @property + def device_class(self) -> str | None: + """Return the device class of the sensor.""" + return self._config.get(CONF_DEVICE_CLASS) diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index cc911cc3431..8b654f7cca0 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -22,8 +22,8 @@ from .mixins import ( CONF_OBJECT_ID, MQTT_AVAILABILITY_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -79,9 +79,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT scene through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, scene.DOMAIN) - ) + await async_discover_yaml_entities(hass, scene.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 0d9f1411fd1..4c302446b19 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -30,8 +30,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -94,9 +94,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT select through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, select.DOMAIN) - ) + await async_discover_yaml_entities(hass, select.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 672e22f632f..6948e173039 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -41,8 +41,8 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -147,9 +147,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, sensor.DOMAIN) - ) + await async_discover_yaml_entities(hass, sensor.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index e7b91274f4f..dfb89d2ee79 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -3,7 +3,6 @@ from __future__ import annotations import copy import functools -import json import logging from typing import Any @@ -32,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_dumps, json_loads from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -51,8 +51,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -143,9 +143,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT siren through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, siren.DOMAIN) - ) + await async_discover_yaml_entities(hass, siren.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry @@ -253,13 +251,13 @@ class MqttSiren(MqttEntity, SirenEntity): json_payload = {STATE: payload} else: try: - json_payload = json.loads(payload) + json_payload = json_loads(payload) _LOGGER.debug( "JSON payload detected after processing payload '%s' on topic %s", json_payload, msg.topic, ) - except json.decoder.JSONDecodeError: + except JSON_DECODE_EXCEPTIONS: _LOGGER.warning( "No valid (JSON) payload detected after processing payload '%s' on topic %s", json_payload, @@ -344,7 +342,7 @@ class MqttSiren(MqttEntity, SirenEntity): payload = ( self._command_templates[template](value, template_variables) if self._command_templates[template] - else json.dumps(template_variables) + else json_dumps(template_variables) ) if payload and payload not in PAYLOAD_NONE: await self.async_publish( diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index dadd5f86f20..b04f2433659 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,8 +37,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -97,9 +97,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT switch through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, switch.DOMAIN) - ) + await async_discover_yaml_entities(hass, switch.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index 7d1f93d30eb..aca4cf0a480 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -1,6 +1,5 @@ """Offer MQTT listening automation rules.""" from contextlib import suppress -import json import logging import voluptuous as vol @@ -12,6 +11,7 @@ from homeassistant.components.automation import ( from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM, CONF_VALUE_TEMPLATE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.json import json_loads from homeassistant.helpers.typing import ConfigType from .. import mqtt @@ -89,7 +89,7 @@ async def async_attach_trigger( } with suppress(ValueError): - data["payload_json"] = json.loads(mqttmsg.payload) + data["payload_json"] = json_loads(mqttmsg.payload) hass.async_run_hass_job(job, {"trigger": data}) diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 694e9530939..c49b8cfa012 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -12,8 +12,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from ..mixins import ( + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, ) from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE @@ -91,9 +91,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT vacuum through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, vacuum.DOMAIN) - ) + await async_discover_yaml_entities(hass, vacuum.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index f25131c43b7..6b957aded5c 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -1,6 +1,4 @@ """Support for Legacy MQTT vacuum.""" -import json - import voluptuous as vol from homeassistant.components.vacuum import ( @@ -14,6 +12,7 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.helpers.json import json_dumps from .. import subscription from ..config import MQTT_BASE_SCHEMA @@ -511,7 +510,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if params: message = {"command": command} message.update(params) - message = json.dumps(message) + message = json_dumps(message) else: message = command await self.async_publish( diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 3d670780994..af6c8d289d8 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -1,6 +1,4 @@ """Support for a State MQTT vacuum.""" -import json - import voluptuous as vol from homeassistant.components.vacuum import ( @@ -21,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import json_dumps, json_loads from .. import subscription from ..config import MQTT_BASE_SCHEMA @@ -203,7 +202,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): @log_messages(self.hass, self.entity_id) def state_message_received(msg): """Handle state MQTT message.""" - payload = json.loads(msg.payload) + payload = json_loads(msg.payload) if STATE in payload and ( payload[STATE] in POSSIBLE_STATES or payload[STATE] is None ): @@ -347,7 +346,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): if params: message = {"command": command} message.update(params) - message = json.dumps(message) + message = json_dumps(message) else: message = command await self.async_publish( diff --git a/homeassistant/components/mullvad/translations/bg.json b/homeassistant/components/mullvad/translations/bg.json index 5d274ec2b73..9862b6b3a2a 100644 --- a/homeassistant/components/mullvad/translations/bg.json +++ b/homeassistant/components/mullvad/translations/bg.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" } } diff --git a/homeassistant/components/myq/config_flow.py b/homeassistant/components/myq/config_flow.py index 8c088de6715..c26b54d7332 100644 --- a/homeassistant/components/myq/config_flow.py +++ b/homeassistant/components/myq/config_flow.py @@ -1,5 +1,7 @@ """Config flow for MyQ integration.""" +from collections.abc import Mapping import logging +from typing import Any import pymyq from pymyq.errors import InvalidCredentialsError, MyQError @@ -7,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -60,7 +63,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._reauth_unique_id = self.context["unique_id"] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 861ae379007..51d0b3290a6 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -1,4 +1,6 @@ """Support for MyQ-Enabled Garage Doors.""" +from typing import Any + from pymyq.const import DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE from pymyq.errors import MyQError @@ -48,26 +50,26 @@ class MyQCover(MyQEntity, CoverEntity): self._attr_unique_id = device.device_id @property - def is_closed(self): + def is_closed(self) -> bool: """Return true if cover is closed, else False.""" return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING @property - def is_open(self): + def is_open(self) -> bool: """Return if the cover is opening or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_OPEN @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" if self.is_closing or self.is_closed: return @@ -90,7 +92,7 @@ class MyQCover(MyQEntity, CoverEntity): if not result: raise HomeAssistantError(f"Closing of cover {self._device.name} failed") - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" if self.is_opening or self.is_open: return diff --git a/homeassistant/components/myq/translations/bg.json b/homeassistant/components/myq/translations/bg.json new file mode 100644 index 00000000000..9c1d3ecccb8 --- /dev/null +++ b/homeassistant/components/myq/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d392624bbe4..3313a70808c 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -7,12 +7,9 @@ from functools import partial import logging from mysensors import BaseAsyncGateway -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_OPTIMISTIC, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry @@ -22,17 +19,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DEVICES, - CONF_BAUD_RATE, - CONF_DEVICE, - CONF_GATEWAYS, - CONF_NODES, - CONF_PERSISTENCE, - CONF_PERSISTENCE_FILE, - CONF_RETAIN, - CONF_TCP_PORT, - CONF_TOPIC_IN_PREFIX, - CONF_TOPIC_OUT_PREFIX, - CONF_VERSION, DOMAIN, MYSENSORS_DISCOVERY, MYSENSORS_GATEWAYS, @@ -48,147 +34,17 @@ from .helpers import on_unload _LOGGER = logging.getLogger(__name__) -CONF_DEBUG = "debug" -CONF_NODE_NAME = "name" - DATA_HASS_CONFIG = "hass_config" -DEFAULT_BAUD_RATE = 115200 -DEFAULT_TCP_PORT = 5003 -DEFAULT_VERSION = "1.4" - -def set_default_persistence_file(value: dict) -> dict: - """Set default persistence file.""" - for idx, gateway in enumerate(value): - if gateway.get(CONF_PERSISTENCE_FILE) is not None: - continue - new_name = f"mysensors{idx + 1}.pickle" - gateway[CONF_PERSISTENCE_FILE] = new_name - - return value - - -def has_all_unique_files(value: list[dict]) -> list[dict]: - """Validate that all persistence files are unique and set if any is set.""" - persistence_files = [gateway[CONF_PERSISTENCE_FILE] for gateway in value] - schema = vol.Schema(vol.Unique()) - schema(persistence_files) - return value - - -def is_persistence_file(value: str) -> str: - """Validate that persistence file path ends in either .pickle or .json.""" - if value.endswith((".json", ".pickle")): - return value - raise vol.Invalid(f"{value} does not end in either `.json` or `.pickle`") - - -def deprecated(key: str) -> Callable[[dict], dict]: - """Mark key as deprecated in configuration.""" - - def validator(config: dict) -> dict: - """Check if key is in config, log warning and remove key.""" - if key not in config: - return config - _LOGGER.warning( - "%s option for %s is deprecated. Please remove %s from your " - "configuration file", - key, - DOMAIN, - key, - ) - config.pop(key) - return config - - return validator - - -NODE_SCHEMA = vol.Schema({cv.positive_int: {vol.Required(CONF_NODE_NAME): cv.string}}) - -GATEWAY_SCHEMA = vol.Schema( - vol.All( - deprecated(CONF_NODES), - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_PERSISTENCE_FILE): vol.All( - cv.string, is_persistence_file - ), - vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, - vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, - vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, - vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, - }, - ) -) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - vol.All( - deprecated(CONF_DEBUG), - deprecated(CONF_OPTIMISTIC), - deprecated(CONF_PERSISTENCE), - { - vol.Required(CONF_GATEWAYS): vol.All( - cv.ensure_list, - set_default_persistence_file, - has_all_unique_files, - [GATEWAY_SCHEMA], - ), - vol.Optional(CONF_RETAIN, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, - }, - ) - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MySensors component.""" + # This is needed to set up the notify platform via discovery. hass.data[DOMAIN] = {DATA_HASS_CONFIG: config} - if DOMAIN not in config or bool(hass.config_entries.async_entries(DOMAIN)): - return True - - config = config[DOMAIN] - user_inputs = [ - { - CONF_DEVICE: gw[CONF_DEVICE], - CONF_BAUD_RATE: gw[CONF_BAUD_RATE], - CONF_TCP_PORT: gw[CONF_TCP_PORT], - CONF_TOPIC_OUT_PREFIX: gw.get(CONF_TOPIC_OUT_PREFIX, ""), - CONF_TOPIC_IN_PREFIX: gw.get(CONF_TOPIC_IN_PREFIX, ""), - CONF_RETAIN: config[CONF_RETAIN], - CONF_VERSION: config[CONF_VERSION], - CONF_PERSISTENCE_FILE: gw[CONF_PERSISTENCE_FILE] - # nodes config ignored at this time. renaming nodes can now be done from the frontend. - } - for gw in config[CONF_GATEWAYS] - ] - user_inputs = [ - {k: v for k, v in userinput.items() if v is not None} - for userinput in user_inputs - ] - - # there is an actual configuration in configuration.yaml, so we have to process it - for user_input in user_inputs: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=user_input, - ) - ) - return True diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index 9d992e172b0..5409e3c9a85 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -22,7 +22,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import DEFAULT_BAUD_RATE, DEFAULT_TCP_PORT, DEFAULT_VERSION, is_persistence_file from .const import ( CONF_BAUD_RATE, CONF_DEVICE, @@ -42,6 +41,17 @@ from .const import ( ) from .gateway import MQTT_COMPONENT, is_serial_port, is_socket_address, try_connect +DEFAULT_BAUD_RATE = 115200 +DEFAULT_TCP_PORT = 5003 +DEFAULT_VERSION = "1.4" + + +def is_persistence_file(value: str) -> str: + """Validate that persistence file path ends in either .pickle or .json.""" + if value.endswith((".json", ".pickle")): + return value + raise vol.Invalid(f"{value} does not end in either `.json` or `.pickle`") + def _get_schema_common(user_input: dict[str, str]) -> dict: """Create a schema with options common to all gateway types.""" @@ -105,31 +115,6 @@ class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Set up config flow.""" self._gw_type: str | None = None - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Import a config entry. - - This method is called by async_setup and it has already - prepared the dict to be compatible with what a user would have - entered from the frontend. - Therefore we process it as though it came from the frontend. - """ - if user_input[CONF_DEVICE] == MQTT_COMPONENT: - user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_MQTT - else: - try: - await self.hass.async_add_executor_job( - is_serial_port, user_input[CONF_DEVICE] - ) - except vol.Invalid: - user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_TCP - else: - user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_SERIAL - - result: FlowResult = await self.async_step_user(user_input=user_input) - if errors := result.get("errors"): - return self.async_abort(reason=next(iter(errors.values()))) - return result - async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: @@ -335,10 +320,11 @@ class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors[CONF_PERSISTENCE_FILE] = "duplicate_persistence_file" break - for other_entry in self._async_current_entries(): - if _is_same_device(gw_type, user_input, other_entry): - errors["base"] = "already_configured" - break + if not errors: + for other_entry in self._async_current_entries(): + if _is_same_device(gw_type, user_input, other_entry): + errors["base"] = "already_configured" + break # if no errors so far, try to connect if not errors and not await try_connect(self.hass, gw_type, user_input): diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 5f3cb6aed96..32e2110dd95 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -11,9 +11,6 @@ ATTR_GATEWAY_ID: Final = "gateway_id" CONF_BAUD_RATE: Final = "baud_rate" CONF_DEVICE: Final = "device" -CONF_GATEWAYS: Final = "gateways" -CONF_NODES: Final = "nodes" -CONF_PERSISTENCE: Final = "persistence" CONF_PERSISTENCE_FILE: Final = "persistence_file" CONF_RETAIN: Final = "retain" CONF_TCP_PORT: Final = "tcp_port" diff --git a/homeassistant/components/mysensors/translations/bg.json b/homeassistant/components/mysensors/translations/bg.json index 69f11b05ce4..7c8e0080bc2 100644 --- a/homeassistant/components/mysensors/translations/bg.json +++ b/homeassistant/components/mysensors/translations/bg.json @@ -1,12 +1,15 @@ { "config": { "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "invalid_port": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043d\u043e\u043c\u0435\u0440 \u043d\u0430 \u043f\u043e\u0440\u0442", "invalid_serial": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u0441\u0435\u0440\u0438\u0435\u043d \u043f\u043e\u0440\u0442", "port_out_of_range": "\u041d\u043e\u043c\u0435\u0440\u044a\u0442 \u043d\u0430 \u043f\u043e\u0440\u0442\u0430 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u0439-\u043c\u0430\u043b\u043a\u043e 1 \u0438 \u043d\u0430\u0439-\u043c\u043d\u043e\u0433\u043e 65535", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_ip": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d IP \u0430\u0434\u0440\u0435\u0441", "invalid_port": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043d\u043e\u043c\u0435\u0440 \u043d\u0430 \u043f\u043e\u0440\u0442", "invalid_serial": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u0441\u0435\u0440\u0438\u0435\u043d \u043f\u043e\u0440\u0442", "port_out_of_range": "\u041d\u043e\u043c\u0435\u0440\u044a\u0442 \u043d\u0430 \u043f\u043e\u0440\u0442\u0430 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u0439-\u043c\u0430\u043b\u043a\u043e 1 \u0438 \u043d\u0430\u0439-\u043c\u043d\u043e\u0433\u043e 65535", @@ -20,6 +23,7 @@ }, "gw_tcp": { "data": { + "device": "IP \u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0448\u043b\u044e\u0437\u0430", "tcp_port": "\u043f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/mysensors/translations/sv.json b/homeassistant/components/mysensors/translations/sv.json new file mode 100644 index 00000000000..fbbcbdff5e6 --- /dev/null +++ b/homeassistant/components/mysensors/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "invalid_serial": "Ogiltig serieport" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 6b0f9db3757..021b46e2f38 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -52,11 +52,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = ConnectionOptions(host=host, username=username, password=password) try: nam = await NettigoAirMonitor.create(websession, options) - except AuthFailed as err: - raise ConfigEntryAuthFailed from err except (ApiError, ClientError, ClientConnectorError, asyncio.TimeoutError) as err: raise ConfigEntryNotReady from err + try: + await nam.async_check_credentials() + except AuthFailed as err: + raise ConfigEntryAuthFailed from err + coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 1727ddff162..3dc2d7f0ba0 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from dataclasses import dataclass import logging from typing import Any @@ -26,6 +28,15 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN + +@dataclass +class NamConfig: + """NAM device configuration class.""" + + mac_address: str + auth_enabled: bool + + _LOGGER = logging.getLogger(__name__) AUTH_SCHEMA = vol.Schema( @@ -33,15 +44,31 @@ AUTH_SCHEMA = vol.Schema( ) -async def async_get_mac(hass: HomeAssistant, host: str, data: dict[str, Any]) -> str: - """Get device MAC address.""" +async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: + """Get device MAC address and auth_enabled property.""" websession = async_get_clientsession(hass) - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + options = ConnectionOptions(host) nam = await NettigoAirMonitor.create(websession, options) async with async_timeout.timeout(10): - return await nam.async_get_mac_address() + mac = await nam.async_get_mac_address() + + return NamConfig(mac, nam.auth_enabled) + + +async def async_check_credentials( + hass: HomeAssistant, host: str, data: dict[str, Any] +) -> None: + """Check if credentials are valid.""" + websession = async_get_clientsession(hass) + + options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + + nam = await NettigoAirMonitor.create(websession, options) + + async with async_timeout.timeout(10): + await nam.async_check_credentials() class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -53,6 +80,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow.""" self.host: str self.entry: config_entries.ConfigEntry + self._config: NamConfig async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -64,9 +92,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.host = user_input[CONF_HOST] try: - mac = await async_get_mac(self.hass, self.host, {}) - except AuthFailed: - return await self.async_step_credentials() + config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" except CannotGetMac: @@ -75,9 +101,12 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id(format_mac(config.mac_address)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) + if config.auth_enabled is True: + return await self.async_step_credentials() + return self.async_create_entry( title=self.host, data=user_input, @@ -97,19 +126,15 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: - mac = await async_get_mac(self.hass, self.host, user_input) + await async_check_credentials(self.hass, self.host, user_input) except AuthFailed: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" - except CannotGetMac: - return self.async_abort(reason="device_unsupported") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(mac)) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) return self.async_create_entry( title=self.host, @@ -131,15 +156,13 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self.host}) try: - mac = await async_get_mac(self.hass, self.host, {}) - except AuthFailed: - return await self.async_step_credentials() + self._config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMac: return self.async_abort(reason="device_unsupported") - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id(format_mac(self._config.mac_address)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) return await self.async_step_confirm_discovery() @@ -156,6 +179,9 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.host}, ) + if self._config.auth_enabled is True: + return await self.async_step_credentials() + self._set_confirm_only() return self.async_show_form( @@ -164,11 +190,11 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" if entry := self.hass.config_entries.async_get_entry(self.context["entry_id"]): self.entry = entry - self.host = data[CONF_HOST] + self.host = entry_data[CONF_HOST] self.context["title_placeholders"] = {"host": self.host} return await self.async_step_reauth_confirm() @@ -180,7 +206,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_get_mac(self.hass, self.host, user_input) + await async_check_credentials(self.hass, self.host, user_input) except (ApiError, AuthFailed, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="reauth_unsuccessful") else: diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index a842af46f84..88048b59162 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -3,7 +3,7 @@ "name": "Nettigo Air Monitor", "documentation": "https://www.home-assistant.io/integrations/nam", "codeowners": ["@bieniu"], - "requirements": ["nettigo-air-monitor==1.2.4"], + "requirements": ["nettigo-air-monitor==1.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/translations/sv.json b/homeassistant/components/nam/translations/sv.json index 15a583f12a2..ffa62b8fae2 100644 --- a/homeassistant/components/nam/translations/sv.json +++ b/homeassistant/components/nam/translations/sv.json @@ -1,13 +1,24 @@ { "config": { "abort": { - "device_unsupported": "Enheten st\u00f6ds ej" + "device_unsupported": "Enheten st\u00f6ds ej", + "reauth_unsuccessful": "\u00c5terautentiseringen misslyckades. Ta bort integrationen och konfigurera den igen." }, "error": { "cannot_connect": "Det gick inte att ansluta ", "unknown": "Ov\u00e4ntat fel" }, "step": { + "credentials": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd" diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index ed63754697a..cab6c8003d0 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Nanoleaf integration.""" from __future__ import annotations +from collections.abc import Mapping import logging import os from typing import Any, Final, cast @@ -77,13 +78,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_link() - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle Nanoleaf reauth flow if token is invalid.""" self.reauth_entry = cast( config_entries.ConfigEntry, self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) - self.nanoleaf = Nanoleaf(async_get_clientsession(self.hass), data[CONF_HOST]) + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), entry_data[CONF_HOST] + ) self.context["title_placeholders"] = {"name": self.reauth_entry.title} return await self.async_step_link() diff --git a/homeassistant/components/nanoleaf/translations/es.json b/homeassistant/components/nanoleaf/translations/es.json index 187517ed478..9899d30d36f 100644 --- a/homeassistant/components/nanoleaf/translations/es.json +++ b/homeassistant/components/nanoleaf/translations/es.json @@ -27,7 +27,10 @@ }, "device_automation": { "trigger_type": { - "swipe_down": "Desliza hacia abajo" + "swipe_down": "Desliza hacia abajo", + "swipe_left": "Deslizar a la izquierda", + "swipe_right": "Deslizar a la derecha", + "swipe_up": "Deslizar hacia arriba" } } } \ No newline at end of file diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 15544371b2e..6b31cf9c05d 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -1,8 +1,8 @@ """Config flow for Neato Botvac.""" from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from homeassistant.config_entries import SOURCE_REAUTH @@ -35,7 +35,7 @@ class OAuth2FlowHandler( return await super().async_step_user(user_input=user_input) - async def async_step_reauth(self, data: MappingProxyType[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py index cb639de4acb..6ee00b2a8b4 100644 --- a/homeassistant/components/neato/hub.py +++ b/homeassistant/components/neato/hub.py @@ -32,7 +32,7 @@ class NeatoHub: def download_map(self, url: str) -> HTTPResponse: """Download a new map image.""" - map_image_data = self.my_neato.get_map_image(url) + map_image_data: HTTPResponse = self.my_neato.get_map_image(url) return map_image_data async def async_update_entry_unique_id(self, entry: ConfigEntry) -> str: diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 9fccee6f64f..2f54b3abde6 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from nessclient import ArmingState +from nessclient import ArmingState, Client import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature @@ -41,19 +41,20 @@ async def async_setup_platform( class NessAlarmPanel(alarm.AlarmControlPanelEntity): """Representation of a Ness alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_should_poll = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.TRIGGER ) - def __init__(self, client, name): + def __init__(self, client: Client, name: str) -> None: """Initialize the alarm panel.""" self._client = client - self._name = name - self._state = None + self._attr_name = name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -61,60 +62,40 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): ) ) - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def code_format(self): - """Return the regex for code format or None if no code is required.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._client.disarm(code) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._client.arm_away(code) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._client.arm_home(code) - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Send trigger/panic command.""" await self._client.panic(code) @callback - def _handle_arming_state_change(self, arming_state): + def _handle_arming_state_change(self, arming_state: ArmingState) -> None: """Handle arming state update.""" if arming_state == ArmingState.UNKNOWN: - self._state = None + self._attr_state = None elif arming_state == ArmingState.DISARMED: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif arming_state == ArmingState.ARMING: - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING elif arming_state == ArmingState.EXIT_DELAY: - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING elif arming_state == ArmingState.ARMED: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif arming_state == ArmingState.ENTRY_DELAY: - self._state = STATE_ALARM_PENDING + self._attr_state = STATE_ALARM_PENDING elif arming_state == ArmingState.TRIGGERED: - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED else: _LOGGER.warning("Unhandled arming state: %s", arming_state) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 0920b37e6ef..b31354b598c 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -22,6 +22,10 @@ from google_nest_sdm.exceptions import ( import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.camera import Image, img_util from homeassistant.components.http.const import KEY_HASS_USER from homeassistant.components.http.view import HomeAssistantView @@ -54,11 +58,14 @@ from . import api, config_flow from .const import ( CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, + CONF_SUBSCRIBER_ID_IMPORTED, DATA_DEVICE_MANAGER, DATA_NEST_CONFIG, DATA_SDM, DATA_SUBSCRIBER, DOMAIN, + INSTALLED_AUTH_DOMAIN, + WEB_AUTH_DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry @@ -70,9 +77,6 @@ from .media_source import ( _LOGGER = logging.getLogger(__name__) -DATA_NEST_UNAVAILABLE = "nest_unavailable" - -NEST_SETUP_NOTIFICATION = "nest_setup" SENSOR_SCHEMA = vol.Schema( {vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)} @@ -112,20 +116,22 @@ THUMBNAIL_SIZE_PX = 175 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_NEST_CONFIG] = config.get(DOMAIN) + + hass.http.register_view(NestEventMediaView(hass)) + hass.http.register_view(NestEventMediaThumbnailView(hass)) if DOMAIN not in config: - return True + return True # ConfigMode.SDM_APPLICATION_CREDENTIALS + + # Note that configuration.yaml deprecation warnings are handled in the + # config entry since we don't know what type of credentials we have and + # whether or not they can be imported. + hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] config_mode = config_flow.get_config_mode(hass) if config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy(hass, config) - config_flow.register_flow_implementation_from_config(hass, config) - - hass.http.register_view(NestEventMediaView(hass)) - hass.http.register_view(NestEventMediaThumbnailView(hass)) - return True @@ -170,10 +176,17 @@ class SignalUpdateCallback: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" - - if DATA_SDM not in entry.data: + config_mode = config_flow.get_config_mode(hass) + if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) + if config_mode == config_flow.ConfigMode.SDM: + await async_import_config(hass, entry) + elif entry.unique_id != entry.data[CONF_PROJECT_ID]: + hass.config_entries.async_update_entry( + entry, unique_id=entry.data[CONF_PROJECT_ID] + ) + subscriber = await api.new_subscriber(hass, entry) if not subscriber: return False @@ -192,57 +205,103 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await subscriber.start_async() except AuthException as err: - _LOGGER.debug("Subscriber authentication error: %s", err) - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + f"Subscriber authentication error: {str(err)}" + ) from err except ConfigurationException as err: _LOGGER.error("Configuration error: %s", err) subscriber.stop_async() return False except SubscriberException as err: - if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]: - _LOGGER.error("Subscriber error: %s", err) - hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True subscriber.stop_async() - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Subscriber error: {str(err)}") from err try: device_manager = await subscriber.async_get_device_manager() except ApiException as err: - if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]: - _LOGGER.error("Device manager error: %s", err) - hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True subscriber.stop_async() - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Device manager error: {str(err)}") from err - hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None) - hass.data[DOMAIN][DATA_SUBSCRIBER] = subscriber - hass.data[DOMAIN][DATA_DEVICE_MANAGER] = device_manager + hass.data[DOMAIN][entry.entry_id] = { + DATA_SUBSCRIBER: subscriber, + DATA_DEVICE_MANAGER: device_manager, + } hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True +async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Attempt to import configuration.yaml settings.""" + config = hass.data[DOMAIN][DATA_NEST_CONFIG] + new_data = { + CONF_PROJECT_ID: config[CONF_PROJECT_ID], + **entry.data, + } + if CONF_SUBSCRIBER_ID not in entry.data: + if CONF_SUBSCRIBER_ID not in config: + raise ValueError("Configuration option 'subscriber_id' missing") + new_data.update( + { + CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID], + CONF_SUBSCRIBER_ID_IMPORTED: True, # Don't delete user managed subscriber + } + ) + hass.config_entries.async_update_entry( + entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID] + ) + + if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: + # App Auth credentials have been deprecated and must be re-created + # by the user in the config flow + raise ConfigEntryAuthFailed( + "Google has deprecated App Auth credentials, and the integration " + "must be reconfigured in the UI to restore access to Nest Devices." + ) + + if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + ), + WEB_AUTH_DOMAIN, + ) + + _LOGGER.warning( + "Configuration of Nest integration in YAML is deprecated and " + "will be removed in a future release; Your existing configuration " + "(including OAuth Application Credentials) has been imported into " + "the UI automatically and can be safely removed from your " + "configuration.yaml file" + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DATA_SDM not in entry.data: # Legacy API return True _LOGGER.debug("Stopping nest subscriber") - subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER] + subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER] subscriber.stop_async() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(DATA_SUBSCRIBER) - hass.data[DOMAIN].pop(DATA_DEVICE_MANAGER) - hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle removal of pubsub subscriptions created during config flow.""" - if DATA_SDM not in entry.data or CONF_SUBSCRIBER_ID not in entry.data: + if ( + DATA_SDM not in entry.data + or CONF_SUBSCRIBER_ID not in entry.data + or CONF_SUBSCRIBER_ID_IMPORTED in entry.data + ): return subscriber = await api.new_subscriber(hass, entry) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 3934b0b3cf1..4d92cc30b1a 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -12,7 +12,6 @@ from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -20,8 +19,6 @@ from .const import ( API_URL, CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, - DATA_NEST_CONFIG, - DOMAIN, OAUTH2_TOKEN, SDM_SCOPES, ) @@ -72,6 +69,36 @@ class AsyncConfigEntryAuth(AbstractAuth): return creds +class AccessTokenAuthImpl(AbstractAuth): + """Authentication implementation used during config flow, without refresh. + + This exists to allow the config flow to use the API before it has fully + created a config entry required by OAuth2Session. This does not support + refreshing tokens, which is fine since it should have been just created. + """ + + def __init__( + self, + websession: ClientSession, + access_token: str, + ) -> None: + """Init the Nest client library auth implementation.""" + super().__init__(websession, API_URL) + self._access_token = access_token + + async def async_get_access_token(self) -> str: + """Return the access token.""" + return self._access_token + + async def async_get_creds(self) -> Credentials: + """Return an OAuth credential for Pub/Sub Subscriber.""" + return Credentials( + token=self._access_token, + token_uri=OAUTH2_TOKEN, + scopes=SDM_SCOPES, + ) + + async def new_subscriber( hass: HomeAssistant, entry: ConfigEntry ) -> GoogleNestSubscriber | None: @@ -81,30 +108,33 @@ async def new_subscriber( hass, entry ) ) - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - if not ( - subscriber_id := entry.data.get( - CONF_SUBSCRIBER_ID, config.get(CONF_SUBSCRIBER_ID) - ) + if not isinstance( + implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): - _LOGGER.error("Configuration option 'subscriber_id' required") - return None - return await new_subscriber_with_impl(hass, entry, subscriber_id, implementation) - - -async def new_subscriber_with_impl( - hass: HomeAssistant, - entry: ConfigEntry, - subscriber_id: str, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, -) -> GoogleNestSubscriber: - """Create a GoogleNestSubscriber, used during ConfigFlow.""" - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + raise ValueError(f"Unexpected auth implementation {implementation}") + if not (subscriber_id := entry.data.get(CONF_SUBSCRIBER_ID)): + raise ValueError("Configuration option 'subscriber_id' missing") auth = AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), - session, - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], + config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), + implementation.client_id, + implementation.client_secret, + ) + return GoogleNestSubscriber(auth, entry.data[CONF_PROJECT_ID], subscriber_id) + + +def new_subscriber_with_token( + hass: HomeAssistant, + access_token: str, + project_id: str, + subscriber_id: str, +) -> GoogleNestSubscriber: + """Create a GoogleNestSubscriber with an access token.""" + return GoogleNestSubscriber( + AccessTokenAuthImpl( + aiohttp_client.async_get_clientsession(hass), + access_token, + ), + project_id, + subscriber_id, ) - return GoogleNestSubscriber(auth, config[CONF_PROJECT_ID], subscriber_id) diff --git a/homeassistant/components/nest/application_credentials.py b/homeassistant/components/nest/application_credentials.py new file mode 100644 index 00000000000..7d88bc37322 --- /dev/null +++ b/homeassistant/components/nest/application_credentials.py @@ -0,0 +1,24 @@ +"""application_credentials platform for nest.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url="", # Overridden in config flow as needs device access project id + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/nest/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/nest/auth.py b/homeassistant/components/nest/auth.py deleted file mode 100644 index 648623b64c7..00000000000 --- a/homeassistant/components/nest/auth.py +++ /dev/null @@ -1,54 +0,0 @@ -"""OAuth implementations.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import ( - INSTALLED_AUTH_DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - OOB_REDIRECT_URI, - WEB_AUTH_DOMAIN, -) - - -class WebAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): - """OAuth implementation using OAuth for web applications.""" - - name = "OAuth for Web" - - def __init__( - self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str - ) -> None: - """Initialize WebAuth.""" - super().__init__( - hass, - WEB_AUTH_DOMAIN, - client_id, - client_secret, - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, - ) - - -class InstalledAppAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): - """OAuth implementation using OAuth for installed applications.""" - - name = "OAuth for Apps" - - def __init__( - self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str - ) -> None: - """Initialize InstalledAppAuth.""" - super().__init__( - hass, - INSTALLED_AUTH_DOMAIN, - client_id, - client_secret, - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, - ) - - @property - def redirect_uri(self) -> str: - """Return the redirect uri.""" - return OOB_REDIRECT_URI diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 6e14100e881..4e38338aee8 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -20,6 +20,7 @@ from google_nest_sdm.exceptions import ApiException from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.camera.const import StreamType +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 @@ -44,7 +45,9 @@ async def async_setup_sdm_entry( ) -> None: """Set up the cameras.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] entities = [] for device in device_manager.devices.values(): if ( @@ -67,6 +70,7 @@ class NestCamera(Camera): self._create_stream_url_lock = asyncio.Lock() self._stream_refresh_unsub: Callable[[], None] | None = None self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits + self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 @property def should_poll(self) -> bool: @@ -175,7 +179,7 @@ class NestCamera(Camera): # Next attempt to catch a url will get a new one self._stream = None if self.stream: - self.stream.stop() + await self.stream.stop() self.stream = None return # Update the stream worker with the latest valid url diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index 6ee988b714f..452c30073da 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -82,7 +82,9 @@ async def async_setup_sdm_entry( ) -> None: """Set up the client entities.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] entities = [] for device in device_manager.devices.values(): if ThermostatHvacTrait.NAME in device.traits: diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index aeebd48abb4..1288592be74 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -1,33 +1,17 @@ """Config flow to configure Nest. This configuration flow supports the following: - - SDM API with Installed app flow where user enters an auth code manually - SDM API with Web OAuth flow with redirect back to Home Assistant - Legacy Nest API auth flow with where user enters an auth code manually NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with -some overrides to support installed app and old APIs auth flow, reauth, -and other custom steps inserted in the middle of the flow. - -The notable config flow steps are: -- user: To dispatch between API versions -- auth: Inserted to add a hook for the installed app flow to accept a token -- async_oauth_create_entry: Overridden to handle when OAuth is complete. This - does not actually create the entry, but holds on to the OAuth token data - for later -- pubsub: Configure the pubsub subscription. Note that subscriptions created - by the config flow are deleted when removed. -- finish: Handles creating a new configuration entry or updating the existing - configuration entry for reauth. - -The SDM API config flow supports a hybrid of configuration.yaml (used as defaults) -and config flow. +some overrides to custom steps inserted in the middle of the flow. """ from __future__ import annotations import asyncio from collections import OrderedDict -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from enum import Enum import logging import os @@ -43,17 +27,15 @@ from google_nest_sdm.exceptions import ( from google_nest_sdm.structure import InfoTrait, Structure import voluptuous as vol -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.typing import ConfigType from homeassistant.util import get_random_string from homeassistant.util.json import load_json -from . import api, auth +from . import api from .const import ( CONF_CLOUD_PROJECT_ID, CONF_PROJECT_ID, @@ -61,14 +43,36 @@ from .const import ( DATA_NEST_CONFIG, DATA_SDM, DOMAIN, - OOB_REDIRECT_URI, + INSTALLED_AUTH_DOMAIN, + OAUTH2_AUTHORIZE, SDM_SCOPES, ) DATA_FLOW_IMPL = "nest_flow_implementation" SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" SUBSCRIPTION_RAND_LENGTH = 10 + +MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration" + +# URLs for Configure Cloud Project step CLOUD_CONSOLE_URL = "https://console.cloud.google.com/home/dashboard" +SDM_API_URL = ( + "https://console.cloud.google.com/apis/library/smartdevicemanagement.googleapis.com" +) +PUBSUB_API_URL = "https://console.cloud.google.com/apis/library/pubsub.googleapis.com" + +# URLs for Configure Device Access Project step +DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" + +# URLs for App Auth deprecation and upgrade +UPGRADE_MORE_INFO_URL = ( + "https://www.home-assistant.io/integrations/nest/#deprecated-app-auth-credentials" +) +DEVICE_ACCESS_CONSOLE_EDIT_URL = ( + "https://console.nest.google.com/device-access/project/{project_id}/information" +) + + _LOGGER = logging.getLogger(__name__) @@ -77,13 +81,15 @@ class ConfigMode(Enum): SDM = 1 # SDM api with configuration.yaml LEGACY = 2 # "Works with Nest" API + SDM_APPLICATION_CREDENTIALS = 3 # Config entry only def get_config_mode(hass: HomeAssistant) -> ConfigMode: """Return the integration configuration mode.""" - if DOMAIN not in hass.data: - return ConfigMode.SDM - config = hass.data[DOMAIN][DATA_NEST_CONFIG] + if DOMAIN not in hass.data or not ( + config := hass.data[DOMAIN].get(DATA_NEST_CONFIG) + ): + return ConfigMode.SDM_APPLICATION_CREDENTIALS if CONF_PROJECT_ID in config: return ConfigMode.SDM return ConfigMode.LEGACY @@ -121,31 +127,6 @@ def register_flow_implementation( } -def register_flow_implementation_from_config( - hass: HomeAssistant, - config: ConfigType, -) -> None: - """Register auth implementations for SDM API from configuration yaml.""" - NestFlowHandler.async_register_implementation( - hass, - auth.InstalledAppAuth( - hass, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - config[DOMAIN][CONF_PROJECT_ID], - ), - ) - NestFlowHandler.async_register_implementation( - hass, - auth.WebAuth( - hass, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - config[DOMAIN][CONF_PROJECT_ID], - ), - ) - - class NestAuthError(HomeAssistantError): """Base class for Nest auth errors.""" @@ -180,7 +161,7 @@ class NestFlowHandler( def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() - self._reauth = False + self._upgrade = False self._data: dict[str, Any] = {DATA_SDM: {}} # Possible name to use for config entry based on the Google Home name self._structure_config_title: str | None = None @@ -190,6 +171,21 @@ class NestFlowHandler( """Return the configuration type for this flow.""" return get_config_mode(self.hass) + def _async_reauth_entry(self) -> ConfigEntry | None: + """Return existing entry for reauth.""" + if self.source != SOURCE_REAUTH or not ( + entry_id := self.context.get("entry_id") + ): + return None + return next( + ( + entry + for entry in self._async_current_entries() + if entry.entry_id == entry_id + ), + None, + ) + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -205,25 +201,28 @@ class NestFlowHandler( "prompt": "consent", } + async def async_generate_authorize_url(self) -> str: + """Generate a url for the user to authorize based on user input.""" + config = self.hass.data.get(DOMAIN, {}).get(DATA_NEST_CONFIG, {}) + project_id = self._data.get(CONF_PROJECT_ID, config.get(CONF_PROJECT_ID, "")) + query = await super().async_generate_authorize_url() + authorize_url = OAUTH2_AUTHORIZE.format(project_id=project_id) + return f"{authorize_url}{query}" + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Complete OAuth setup and finish pubsub or finish.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" self._data.update(data) - if not self._configure_pubsub(): + if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") return await self.async_step_finish() return await self.async_step_pubsub() - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - if user_input is None: - _LOGGER.error("Reauth invoked with empty config entry data") - return self.async_abort(reason="missing_configuration") - self._reauth = True - self._data.update(user_input) + self._data.update(entry_data) + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -233,98 +232,187 @@ class NestFlowHandler( assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" if user_input is None: return self.async_show_form(step_id="reauth_confirm") - existing_entries = self._async_current_entries() - if existing_entries: - # Pick an existing auth implementation for Reauth if present. Note - # only one ConfigEntry is allowed so its safe to pick the first. - entry = next(iter(existing_entries)) - if "auth_implementation" in entry.data: - data = {"implementation": entry.data["auth_implementation"]} - return await self.async_step_user(data) + if self._data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: + # The config entry points to an auth mechanism that no longer works and the + # user needs to take action in the google cloud console to resolve. First + # prompt to create app creds, then later ensure they've updated the device + # access console. + self._upgrade = True + implementations = await config_entry_oauth2_flow.async_get_implementations( + self.hass, self.DOMAIN + ) + if not implementations: + return await self.async_step_auth_upgrade() return await self.async_step_user() + async def async_step_auth_upgrade( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Give instructions for upgrade of deprecated app auth.""" + assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" + if user_input is None: + return self.async_show_form( + step_id="auth_upgrade", + description_placeholders={ + "more_info_url": UPGRADE_MORE_INFO_URL, + }, + ) + # Abort this flow and ask the user for application credentials. The frontend + # will restart a new config flow after the user finishes so schedule a new + # re-auth config flow for the same entry so the user may resume. + if reauth_entry := self._async_reauth_entry(): + self.hass.async_add_job(reauth_entry.async_start_reauth, self.hass) + return self.async_abort(reason="missing_credentials") + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - if self.config_mode == ConfigMode.SDM: - # Reauth will update an existing entry - if self._async_current_entries() and not self._reauth: - return self.async_abort(reason="single_instance_allowed") + if self.config_mode == ConfigMode.LEGACY: + return await self.async_step_init(user_input) + self._data[DATA_SDM] = {} + if self.source == SOURCE_REAUTH: return await super().async_step_user(user_input) - return await self.async_step_init(user_input) + # Application Credentials setup needs information from the user + # before creating the OAuth URL + return await self.async_step_create_cloud_project() + + async def async_step_create_cloud_project( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle initial step in app credentails flow.""" + implementations = await config_entry_oauth2_flow.async_get_implementations( + self.hass, self.DOMAIN + ) + if implementations: + return await self.async_step_cloud_project() + # This informational step explains to the user how to setup the + # cloud console and other pre-requisites needed before setting up + # an application credential. This extra step also allows discovery + # to start the config flow rather than aborting. The abort step will + # redirect the user to the right panel in the UI then return with a + # valid auth implementation. + if user_input is not None: + return self.async_abort(reason="missing_credentials") + return self.async_show_form( + step_id="create_cloud_project", + description_placeholders={ + "cloud_console_url": CLOUD_CONSOLE_URL, + "sdm_api_url": SDM_API_URL, + "pubsub_api_url": PUBSUB_API_URL, + "more_info_url": MORE_INFO_URL, + }, + ) + + async def async_step_cloud_project( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle cloud project in user input.""" + if user_input is not None: + self._data.update(user_input) + return await self.async_step_device_project() + return self.async_show_form( + step_id="cloud_project", + data_schema=vol.Schema( + { + vol.Required(CONF_CLOUD_PROJECT_ID): str, + } + ), + description_placeholders={ + "cloud_console_url": CLOUD_CONSOLE_URL, + "more_info_url": MORE_INFO_URL, + }, + ) + + async def async_step_device_project( + self, user_input: dict | None = None + ) -> FlowResult: + """Collect device access project from user input.""" + errors = {} + if user_input is not None: + project_id = user_input[CONF_PROJECT_ID] + if project_id == self._data[CONF_CLOUD_PROJECT_ID]: + _LOGGER.error( + "Device Access Project ID and Cloud Project ID must not be the same, see documentation" + ) + errors[CONF_PROJECT_ID] = "wrong_project_id" + else: + self._data.update(user_input) + await self.async_set_unique_id(project_id) + self._abort_if_unique_id_configured() + return await super().async_step_user() + + return self.async_show_form( + step_id="device_project", + data_schema=vol.Schema( + { + vol.Required(CONF_PROJECT_ID): str, + } + ), + description_placeholders={ + "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, + "more_info_url": MORE_INFO_URL, + }, + errors=errors, + ) async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Create an entry for auth.""" - if self.flow_impl.domain == "nest.installed": - # The default behavior from the parent class is to redirect the - # user with an external step. When using installed app auth, we - # instead prompt the user to sign in and copy/paste and - # authentication code back into this form. - # Note: This is similar to the Legacy API flow below, but it is - # simpler to reuse the OAuth logic in the parent class than to - # reuse SDM code with Legacy API code. - if user_input is not None: - self.external_data = { - "code": user_input["code"], - "state": {"redirect_uri": OOB_REDIRECT_URI}, - } - return await super().async_step_creation(user_input) - - result = await super().async_step_auth() - return self.async_show_form( - step_id="auth", - description_placeholders={"url": result["url"]}, - data_schema=vol.Schema({vol.Required("code"): str}), - ) + """Verify any last pre-requisites before sending user through OAuth flow.""" + if user_input is None and self._upgrade: + # During app auth upgrade we need the user to update their device access project + # before we redirect to the authentication flow. + return await self.async_step_device_project_upgrade() return await super().async_step_auth(user_input) - def _configure_pubsub(self) -> bool: - """Return True if the config flow should configure Pub/Sub.""" - if self._reauth: - # Just refreshing tokens and preserving existing subscriber id - return False - if CONF_SUBSCRIBER_ID in self.hass.data[DOMAIN][DATA_NEST_CONFIG]: - # Hard coded configuration.yaml skips pubsub in config flow - return False - # No existing subscription configured, so create in config flow - return True + async def async_step_device_project_upgrade( + self, user_input: dict | None = None + ) -> FlowResult: + """Update the device access project.""" + if user_input is not None: + # Resume OAuth2 redirects + return await super().async_step_auth() + if not isinstance( + self.flow_impl, config_entry_oauth2_flow.LocalOAuth2Implementation + ): + raise ValueError(f"Unexpected OAuth implementation: {self.flow_impl}") + client_id = self.flow_impl.client_id + return self.async_show_form( + step_id="device_project_upgrade", + description_placeholders={ + "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( + project_id=self._data[CONF_PROJECT_ID] + ), + "more_info_url": UPGRADE_MORE_INFO_URL, + "client_id": client_id, + }, + ) async def async_step_pubsub( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure and create Pub/Sub subscriber.""" - # Populate data from the previous config entry during reauth, then - # overwrite with the user entered values. - data = {} - if self._reauth: - data.update(self._data) - if user_input: - data.update(user_input) + data = { + **self._data, + **(user_input if user_input is not None else {}), + } cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip() + config = self.hass.data.get(DOMAIN, {}).get(DATA_NEST_CONFIG, {}) + project_id = data.get(CONF_PROJECT_ID, config.get(CONF_PROJECT_ID)) - errors = {} - config = self.hass.data[DOMAIN][DATA_NEST_CONFIG] - if cloud_project_id == config[CONF_PROJECT_ID]: - _LOGGER.error( - "Wrong Project ID. Device Access Project ID used, but expected Cloud Project ID" - ) - errors[CONF_CLOUD_PROJECT_ID] = "wrong_project_id" - - if user_input is not None and not errors: + errors: dict[str, str] = {} + if cloud_project_id: # Create the subscriber id and/or verify it already exists. Note that # the existing id is used, and create call below is idempotent if not (subscriber_id := data.get(CONF_SUBSCRIBER_ID, "")): subscriber_id = _generate_subscription_id(cloud_project_id) _LOGGER.debug("Creating subscriber id '%s'", subscriber_id) - # Create a placeholder ConfigEntry to use since with the auth we've already created. - entry = ConfigEntry( - version=1, domain=DOMAIN, title="", data=self._data, source="" - ) - subscriber = await api.new_subscriber_with_impl( - self.hass, entry, subscriber_id, self.flow_impl + subscriber = api.new_subscriber_with_token( + self.hass, + self._data["token"]["access_token"], + project_id, + subscriber_id, ) try: await subscriber.create_subscription() @@ -371,22 +459,13 @@ class NestFlowHandler( async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult: """Create an entry for the SDM flow.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - await self.async_set_unique_id(DOMAIN) - # Update existing config entry when in the reauth flow. This - # integration only supports one config entry so remove any prior entries - # added before the "single_instance_allowed" check was added - existing_entries = self._async_current_entries() - if existing_entries: - updated = False - for entry in existing_entries: - if updated: - await self.hass.config_entries.async_remove(entry.entry_id) - continue - updated = True - self.hass.config_entries.async_update_entry( - entry, data=self._data, unique_id=DOMAIN - ) - await self.hass.config_entries.async_reload(entry.entry_id) + # Update existing config entry when in the reauth flow. + if entry := self._async_reauth_entry(): + self.hass.config_entries.async_update_entry( + entry, + data=self._data, + ) + await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="reauth_successful") title = self.flow_impl.name if self._structure_config_title: diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index bd951756eae..64c27c1643b 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -11,6 +11,7 @@ INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed" CONF_PROJECT_ID = "project_id" CONF_SUBSCRIBER_ID = "subscriber_id" +CONF_SUBSCRIBER_ID_IMPORTED = "subscriber_id_imported" CONF_CLOUD_PROJECT_ID = "cloud_project_id" SIGNAL_NEST_UPDATE = "nest_update" @@ -25,4 +26,3 @@ SDM_SCOPES = [ "https://www.googleapis.com/auth/pubsub", ] API_URL = "https://smartdevicemanagement.googleapis.com/v1" -OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index b9aa52aa2c6..2d2b01d3849 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -2,12 +2,16 @@ from __future__ import annotations +from collections.abc import Mapping + from google_nest_sdm.device import Device from google_nest_sdm.device_traits import InfoTrait +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo -from .const import DOMAIN +from .const import DATA_DEVICE_MANAGER, DOMAIN DEVICE_TYPE_MAP: dict[str, str] = { "sdm.devices.types.CAMERA": "Camera", @@ -66,3 +70,27 @@ class NestDeviceInfo: names = [name for id, name in items] return " ".join(names) return None + + +@callback +def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]: + """Return a mapping of all nest devices for all config entries.""" + devices = {} + for entry_id in hass.data[DOMAIN]: + if not (device_manager := hass.data[DOMAIN][entry_id].get(DATA_DEVICE_MANAGER)): + continue + devices.update( + {device.name: device for device in device_manager.devices.values()} + ) + return devices + + +@callback +def async_nest_devices_by_device_id(hass: HomeAssistant) -> Mapping[str, Device]: + """Return a mapping of all nest devices by home assistant device id, for all config entries.""" + device_registry = dr.async_get(hass) + devices = {} + for nest_device_id, device in async_nest_devices(hass).items(): + if device_entry := device_registry.async_get_device({(DOMAIN, nest_device_id)}): + devices[device_entry.id] = device + return devices diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 05769a407f2..cb546c87ee4 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -1,7 +1,6 @@ """Provides device automations for Nest.""" from __future__ import annotations -from google_nest_sdm.device_manager import DeviceManager import voluptuous as vol from homeassistant.components.automation import ( @@ -14,11 +13,11 @@ from homeassistant.components.device_automation.exceptions import ( ) from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import DATA_DEVICE_MANAGER, DOMAIN +from .const import DOMAIN +from .device_info import async_nest_devices_by_device_id from .events import DEVICE_TRAIT_TRIGGER_MAP, NEST_EVENT DEVICE = "device" @@ -32,43 +31,18 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -@callback -def async_get_nest_device_id(hass: HomeAssistant, device_id: str) -> str | None: - """Get the nest API device_id from the HomeAssistant device_id.""" - device_registry = dr.async_get(hass) - if device := device_registry.async_get(device_id): - for (domain, unique_id) in device.identifiers: - if domain == DOMAIN: - return unique_id - return None - - -@callback -def async_get_device_trigger_types( - hass: HomeAssistant, nest_device_id: str -) -> list[str]: - """List event triggers supported for a Nest device.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] - if not (nest_device := device_manager.devices.get(nest_device_id)): - raise InvalidDeviceAutomationConfig(f"Nest device not found {nest_device_id}") - - # Determine the set of event types based on the supported device traits - trigger_types = [ - trigger_type - for trait in nest_device.traits - if (trigger_type := DEVICE_TRAIT_TRIGGER_MAP.get(trait)) - ] - return trigger_types - - async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for a Nest device.""" - nest_device_id = async_get_nest_device_id(hass, device_id) - if not nest_device_id: + devices = async_nest_devices_by_device_id(hass) + if not (device := devices.get(device_id)): raise InvalidDeviceAutomationConfig(f"Device not found {device_id}") - trigger_types = async_get_device_trigger_types(hass, nest_device_id) + trigger_types = [ + trigger_type + for trait in device.traits + if (trigger_type := DEVICE_TRAIT_TRIGGER_MAP.get(trait)) + ] return [ { CONF_PLATFORM: DEVICE, diff --git a/homeassistant/components/nest/diagnostics.py b/homeassistant/components/nest/diagnostics.py index c21842d5939..d350b719608 100644 --- a/homeassistant/components/nest/diagnostics.py +++ b/homeassistant/components/nest/diagnostics.py @@ -27,10 +27,15 @@ def _async_get_nest_devices( if DATA_SDM not in config_entry.data: return {} - if DATA_DEVICE_MANAGER not in hass.data[DOMAIN]: + if ( + config_entry.entry_id not in hass.data[DOMAIN] + or DATA_DEVICE_MANAGER not in hass.data[DOMAIN][config_entry.entry_id] + ): return {} - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][config_entry.entry_id][ + DATA_DEVICE_MANAGER + ] return device_manager.devices diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index ce0b68c782a..d0588d46f06 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -2,10 +2,10 @@ "domain": "nest", "name": "Nest", "config_flow": true, - "dependencies": ["ffmpeg", "http", "auth"], + "dependencies": ["ffmpeg", "http", "application_credentials"], "after_dependencies": ["media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.2.0", "google-nest-sdm==1.8.0"], + "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.0.0"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index e4e26153b3a..4614d4b1ed4 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -25,7 +25,6 @@ import os from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.event import EventImageType, ImageEventBase from google_nest_sdm.event_media import ( ClipPreviewSession, @@ -57,8 +56,8 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util import dt as dt_util -from .const import DATA_DEVICE_MANAGER, DOMAIN -from .device_info import NestDeviceInfo +from .const import DOMAIN +from .device_info import NestDeviceInfo, async_nest_devices_by_device_id from .events import EVENT_NAME_MAP, MEDIA_SOURCE_EVENT_TITLE_MAP _LOGGER = logging.getLogger(__name__) @@ -271,21 +270,13 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @callback def async_get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]: """Return a mapping of device id to eligible Nest event media devices.""" - if DATA_DEVICE_MANAGER not in hass.data[DOMAIN]: - # Integration unloaded, or is legacy nest integration - return {} - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] - device_registry = dr.async_get(hass) - devices = {} - for device in device_manager.devices.values(): - if not ( - CameraEventImageTrait.NAME in device.traits - or CameraClipPreviewTrait.NAME in device.traits - ): - continue - if device_entry := device_registry.async_get_device({(DOMAIN, device.name)}): - devices[device_entry.id] = device - return devices + devices = async_nest_devices_by_device_id(hass) + return { + device_id: device + for device_id, device in devices.items() + if CameraEventImageTrait.NAME in device.traits + or CameraClipPreviewTrait.NAME in device.traits + } @dataclass diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index d33aa3eff8b..c6d1c8b2b30 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -36,7 +36,9 @@ async def async_setup_sdm_entry( ) -> None: """Set up the sensors.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] entities: list[SensorEntity] = [] for device in device_manager.devices.values(): if TemperatureTrait.NAME in device.traits: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 1d3dfda1708..0a13de41511 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -1,16 +1,38 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." + }, "config": { "step": { + "auth_upgrade": { + "title": "Nest: App Auth Deprecation", + "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices." + }, + "device_project_upgrade": { + "title": "Nest: Update Device Access Project", + "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`" + }, + "create_cloud_project": { + "title": "Nest: Create and configure Cloud Project", + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up." + }, + "cloud_project": { + "title": "Nest: Enter Cloud Project ID", + "description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).", + "data": { + "cloud_project_id": "Google Cloud Project ID" + } + }, + "device_project": { + "title": "Nest: Create a Device Access Project", + "description": "Create a Nest Device Access project which **requires a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "data": { + "project_id": "Device Access Project ID" + } + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account", - "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", - "data": { - "code": "[%key:common::config_flow::data::access_token%]" - } - }, "pubsub": { "title": "Configure Google Cloud", "description": "Visit the [Cloud Console]({url}) to find your Google Cloud Project ID.", @@ -43,11 +65,11 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "internal_error": "Internal error validating code", "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", - "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)", + "wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)", "subscriber_error": "Unknown subscriber error, see logs" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]", diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index 888b7ed5b44..791be9975eb 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Segueix les [instruccions]({more_info_url}) per configurar la Cloud Console: \n\n1. V\u00e9s a la [pantalla de consentiment OAuth]({oauth_consent_url}) i configura\n2. V\u00e9s a [Credencials]({oauth_creds_url}) i fes clic a **Crear credencials**.\n3. A la llista desplegable, selecciona **ID de client OAuth**.\n4. Selecciona **Aplicaci\u00f3 web** al tipus d'aplicaci\u00f3.\n5. Afegeix `{redirect_url}` a *URI de redirecci\u00f3 autoritzat*." + }, "config": { "abort": { + "already_configured": "El compte ja est\u00e0 configurat", "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", @@ -19,7 +23,7 @@ "subscriber_error": "Error de subscriptor desconegut, consulta els registres", "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi.", "unknown": "Error inesperat", - "wrong_project_id": "Introdueix un ID de projecte Cloud v\u00e0lid (s'ha trobat un ID de projecte d'Acc\u00e9s de Dispositiu)" + "wrong_project_id": "Introdueix un ID de projecte Cloud v\u00e0lid (era el mateix que l'ID de projecte Device Access)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "Per enlla\u00e7ar un compte de Google, [autoritza el compte]({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copia i enganxa a continuaci\u00f3 el codi 'token' d'autenticaci\u00f3 proporcionat.", "title": "Vinculaci\u00f3 amb compte de Google" }, + "auth_upgrade": { + "description": "Google ha deixat d'utilitzar l'autenticaci\u00f3 d'aplicacions per millorar la seguretat i has de crear noves credencials d'aplicaci\u00f3. \n\nConsulta la [documentaci\u00f3]({more_info_url}) i segueix els passos que et guiaran per tornar a tenir acc\u00e9s als teus dispositius Nest.", + "title": "Nest: l'autenticaci\u00f3 d'aplicaci\u00f3 s'acaba" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID de projecte Google Cloud" + }, + "description": "Introdueix l'identificador de projecte Cloud a continuaci\u00f3, per exemple, *exemple-projecte-12345*. Consulta [Google Cloud Console]({cloud_console_url}) o la [documentaci\u00f3]({more_info_url}) per a m\u00e9s informaci\u00f3.", + "title": "Nest: introdueix l'identificador del projecte Cloud" + }, + "create_cloud_project": { + "description": "La integraci\u00f3 Nest et permet integrar els termostats, c\u00e0meres i timbres de Nest mitjan\u00e7ant l'API de gesti\u00f3 de dispositius intel\u00b7ligents. L'API SDM **estableix una tarifa de configuraci\u00f3 de 5\u202f$** nom\u00e9s la primera vegada. Consulta la documentaci\u00f3 per a [m\u00e9s informaci\u00f3]({more_info_url}). \n\n1. V\u00e9s a [Google Cloud Console]({cloud_console_url}).\n2. Si aquest \u00e9s el teu primer projecte, fes clic a **Crea projecte** i despr\u00e9s a **Projecte nou**.\n3. D\u00f3na-li un nom al teu projecte Cloud i fes clic a **Crea**.\n4. Desa l'identificador del projecte Cloud, per exemple, *exemple-projecte-12345*, ja que el necessitar\u00e0s m\u00e9s endavant\n5. V\u00e9s a la Biblioteca API de [API de gesti\u00f3 de dispositius intel\u00b7ligents]({sdm_api_url}) i fes clic a **Activar** ('Enable').\n6. V\u00e9s a la Biblioteca API de [API Cloud Pub/Sub]({pubsub_api_url}) i fes clic a **Activar** ('Enable'). \n\nContinua quan el teu projecte Cloud estigui configurat.", + "title": "Nest: crea i configura el projecte Cloud" + }, + "device_project": { + "data": { + "project_id": "ID de projecte Device Access" + }, + "description": "Crea un projecte d'acc\u00e9s a dispositius Nest que **requereix una tarifa de 5 $** per configurar-lo.\n1. V\u00e9s a [Consola d'acc\u00e9s al dispositiu]({device_access_console_url}) i a trav\u00e9s del flux de pagament.\n2. Fes clic a **Crea projecte**.\n3. D\u00f3na-li un nom al projecte d'acc\u00e9s a dispositius i feu clic a **Seg\u00fcent**.\n4. Introdueix el teu ID de client OAuth.\n5. Activa els esdeveniments fent clic a **Activa** i **Crea projecte**. \n\nIntrodueix el teu ID de projecte d'acc\u00e9s a dispositiu a continuaci\u00f3 ([m\u00e9s informaci\u00f3]({more_info_url})).\n", + "title": "Nest: crea un projecte Device Access" + }, + "device_project_upgrade": { + "description": "Actualitza el projecte d'acc\u00e9s al dispositiu Nest amb el teu nou ID de client OAuth ([m\u00e9s informaci\u00f3]({more_info_url}))\n1. V\u00e9s a la [Consola d'acc\u00e9s al dispositiu]({device_access_console_url}).\n2. Fes clic a la icona de la paperera que hi ha al costat de *OAuth Client ID*.\n3. Feu clic al men\u00fa desplegable `...` i *Afegeix un ID de client*.\n4. Introdueix el teu nou ID de client OAuth i feu clic a **Afegeix**.\n\nEl teu ID de client OAuth \u00e9s: `{client_id}`", + "title": "Nest: actualitza projecte Device Access" + }, "init": { "data": { "flow_impl": "Prove\u00efdor" diff --git a/homeassistant/components/nest/translations/de.json b/homeassistant/components/nest/translations/de.json index 47d0505dca5..933231fd1e8 100644 --- a/homeassistant/components/nest/translations/de.json +++ b/homeassistant/components/nest/translations/de.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Folgen Sie den [Anweisungen]({more_info_url}), um die Cloud-Konsole zu konfigurieren:\n\n1. Gehen Sie zum [OAuth-Zustimmungsbildschirm]({oauth_consent_url}) und konfigurieren Sie\n1. Gehen Sie zu [Credentials]({oauth_creds_url}) und klicken Sie auf **Create Credentials**.\n1. W\u00e4hlen Sie in der Dropdown-Liste **OAuth-Client-ID**.\n1. W\u00e4hlen Sie **Webanwendung** f\u00fcr den Anwendungstyp.\n1. F\u00fcgen Sie `{redirect_url}` unter *Authorized redirect URI* hinzu." + }, "config": { "abort": { + "already_configured": "Konto wurde bereits konfiguriert", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", "invalid_access_token": "Ung\u00fcltiger Zugriffs-Token", "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", @@ -19,7 +23,7 @@ "subscriber_error": "Unbekannter Abonnentenfehler, siehe Protokolle", "timeout": "Ein zeit\u00fcberschreitungs Fehler ist aufgetreten", "unknown": "Unerwarteter Fehler", - "wrong_project_id": "Bitte gib eine g\u00fcltige Cloud-Projekt-ID ein (gefundene Ger\u00e4tezugriffs-Projekt-ID)" + "wrong_project_id": "Gib eine g\u00fcltige Cloud-Projekt-ID ein (identisch mit der Projekt-ID f\u00fcr den Ger\u00e4tezugriff)." }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "Um dein Google-Konto zu verkn\u00fcpfen, w\u00e4hle [Konto autorisieren]({url}).\n\nKopiere nach der Autorisierung den unten angegebenen Authentifizierungstoken-Code.", "title": "Google-Konto verkn\u00fcpfen" }, + "auth_upgrade": { + "description": "App Auth wurde von Google abgeschafft, um die Sicherheit zu verbessern, und Sie m\u00fcssen Ma\u00dfnahmen ergreifen, indem Sie neue Anmeldedaten f\u00fcr die Anwendung erstellen.\n\n\u00d6ffnen Sie die [Dokumentation]({more_info_url}) und folgen Sie den n\u00e4chsten Schritten, die Sie durchf\u00fchren m\u00fcssen, um den Zugriff auf Ihre Nest-Ger\u00e4te wiederherzustellen.", + "title": "Nest: Einstellung der App-Authentifizierung" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Projekt-ID" + }, + "description": "Geben Sie unten die Cloud-Projekt-ID ein, z. B. *example-project-12345*. Siehe die [Google Cloud Console]({cloud_console_url}) oder die Dokumentation f\u00fcr [weitere Informationen]({more_info_url}).", + "title": "Nest: Cloud-Projekt-ID eingeben" + }, + "create_cloud_project": { + "description": "Die Nest-Integration erm\u00f6glicht es Ihnen, Ihre Nest-Thermostate, -Kameras und -T\u00fcrklingeln \u00fcber die Smart Device Management API zu integrieren. Die SDM API **erfordert eine einmalige Einrichtungsgeb\u00fchr von US $5**. Siehe Dokumentation f\u00fcr [weitere Informationen]({more_info_url}).\n\n1. Rufen Sie die [Google Cloud Console]({cloud_console_url}) auf.\n1. Wenn dies Ihr erstes Projekt ist, klicken Sie auf **Projekt erstellen** und dann auf **Neues Projekt**.\n1. Geben Sie Ihrem Cloud-Projekt einen Namen und klicken Sie dann auf **Erstellen**.\n1. Speichern Sie die Cloud Project ID, z. B. *example-project-12345*, da Sie diese sp\u00e4ter ben\u00f6tigen.\n1. Gehen Sie zur API-Bibliothek f\u00fcr [Smart Device Management API]({sdm_api_url}) und klicken Sie auf **Aktivieren**.\n1. Wechseln Sie zur API-Bibliothek f\u00fcr [Cloud Pub/Sub API]({pubsub_api_url}) und klicken Sie auf **Aktivieren**.\n\nFahren Sie fort, wenn Ihr Cloud-Projekt eingerichtet ist.", + "title": "Nest: Cloud-Projekt erstellen und konfigurieren" + }, + "device_project": { + "data": { + "project_id": "Ger\u00e4tezugriffsprojekt ID" + }, + "description": "Erstellen Sie ein Nest Ger\u00e4tezugriffsprojekt, f\u00fcr dessen Einrichtung **eine Geb\u00fchr von 5 US-Dollar** anf\u00e4llt.\n1. Gehen Sie zur [Device Access Console]({device_access_console_url}) und durchlaufen Sie den Zahlungsablauf.\n1. Klicken Sie auf **Projekt erstellen**.\n1. Geben Sie Ihrem Device Access-Projekt einen Namen und klicken Sie auf **Weiter**.\n1. Geben Sie Ihre OAuth-Client-ID ein\n1. Aktivieren Sie Ereignisse, indem Sie auf **Aktivieren** und **Projekt erstellen** klicken.\n\nGeben Sie unten Ihre Ger\u00e4tezugriffsprojekt ID ein ([more info]({more_info_url})).\n", + "title": "Nest: Erstelle ein Ger\u00e4tezugriffsprojekt" + }, + "device_project_upgrade": { + "description": "Aktualisieren Sie das Nest Ger\u00e4tezugriffsprojekt mit Ihrer neuen OAuth Client ID ([more info]({more_info_url}))\n1. Gehen Sie zur [Ger\u00e4tezugriffskonsole]({device_access_console_url}).\n1. Klicken Sie auf das Papierkorbsymbol neben *OAuth Client ID*.\n1. Klicken Sie auf das \u00dcberlaufmen\u00fc und *Client ID hinzuf\u00fcgen*.\n1. Geben Sie Ihre neue OAuth-Client-ID ein und klicken Sie auf **Hinzuf\u00fcgen**.\n\nIhre OAuth-Client-ID lautet: `{client_id}`", + "title": "Nest: Aktualisiere das Ger\u00e4tezugriffsprojekt" + }, "init": { "data": { "flow_impl": "Anbieter" diff --git a/homeassistant/components/nest/translations/el.json b/homeassistant/components/nest/translations/el.json index 1628a721733..b94ec0ee9df 100644 --- a/homeassistant/components/nest/translations/el.json +++ b/homeassistant/components/nest/translations/el.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "\u0391\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b9\u03c2 [\u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2]( {more_info_url} ) \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u039a\u03bf\u03bd\u03c3\u03cc\u03bb\u03b1 Cloud: \n\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [\u03bf\u03b8\u03cc\u03bd\u03b7 \u03c3\u03c5\u03bd\u03b1\u03af\u03bd\u03b5\u03c3\u03b7\u03c2 OAuth]( {oauth_consent_url} ) \u03ba\u03b1\u03b9 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03c3\u03c4\u03b5\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b1 [\u0394\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1] ( {oauth_creds_url} ) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03b7\u03c1\u03af\u03c9\u03bd**.\n 1. \u0391\u03c0\u03cc \u03c4\u03b7\u03bd \u03b1\u03bd\u03b1\u03c0\u03c4\u03c5\u03c3\u03c3\u03cc\u03bc\u03b5\u03bd\u03b7 \u03bb\u03af\u03c3\u03c4\u03b1 \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **OAuth \u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7**.\n 1. \u0395\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **\u0395\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae \u0399\u03c3\u03c4\u03bf\u03cd** \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03a4\u03cd\u03c0\u03bf \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2.\n 1. \u03a0\u03c1\u03bf\u03c3\u03b8\u03ad\u03c3\u03c4\u03b5 \u03c4\u03bf \" {redirect_url} \" \u03c3\u03c4\u03bf *Authorized redirect URI*." + }, "config": { "abort": { + "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", "authorize_url_timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7\u03c2.", "invalid_access_token": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03b4\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", "missing_configuration": "\u03a4\u03bf \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03bf \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7.", @@ -29,6 +33,32 @@ "description": "\u0393\u03b9\u03b1 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc \u03c3\u03b1\u03c2 \u03c3\u03c4\u03bf Google, [\u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03bf\u03c4\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc \u03c3\u03b1\u03c2]({url}).\n\n\u039c\u03b5\u03c4\u03ac \u03c4\u03b7\u03bd \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7, \u03b1\u03bd\u03c4\u03b9\u03b3\u03c1\u03ac\u03c8\u03c4\u03b5-\u03b5\u03c0\u03b9\u03ba\u03bf\u03bb\u03bb\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf\u03bd \u03c0\u03b1\u03c1\u03b1\u03ba\u03ac\u03c4\u03c9 \u03c0\u03b1\u03c1\u03b5\u03c7\u03cc\u03bc\u03b5\u03bd\u03bf \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc Auth Token.", "title": "\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03bf\u03cd Google" }, + "auth_upgrade": { + "description": "\u03a4\u03bf App Auth \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af \u03b1\u03c0\u03cc \u03c4\u03b7\u03bd Google \u03b3\u03b9\u03b1 \u03b2\u03b5\u03bb\u03c4\u03af\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b1\u03c3\u03c6\u03ac\u03bb\u03b5\u03b9\u03b1\u03c2 \u03ba\u03b1\u03b9 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03c1\u03bf\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03b5 \u03b5\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b5\u03c2 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ce\u03bd\u03c4\u03b1\u03c2 \u03bd\u03ad\u03b1 \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1 \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2. \n\n \u0391\u03bd\u03bf\u03af\u03be\u03c4\u03b5 \u03c4\u03bf [documentation]( {more_info_url} ) \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03b5\u03c4\u03b5, \u03ba\u03b1\u03b8\u03ce\u03c2 \u03c4\u03b1 \u03b5\u03c0\u03cc\u03bc\u03b5\u03bd\u03b1 \u03b2\u03ae\u03bc\u03b1\u03c4\u03b1 \u03b8\u03b1 \u03c3\u03b1\u03c2 \u03ba\u03b1\u03b8\u03bf\u03b4\u03b7\u03b3\u03ae\u03c3\u03bf\u03c5\u03bd \u03c3\u03c4\u03b1 \u03b2\u03ae\u03bc\u03b1\u03c4\u03b1 \u03c0\u03bf\u03c5 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03b5\u03c4\u03b5 \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03ad\u03c1\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7 \u03c3\u03c4\u03b9\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 Nest.", + "title": "Nest: \u039a\u03b1\u03c4\u03ac\u03c1\u03b3\u03b7\u03c3\u03b7 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2" + }, + "cloud_project": { + "data": { + "cloud_project_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 Google Cloud" + }, + "description": "\u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c4\u03bf\u03c5 \u03ad\u03c1\u03b3\u03bf\u03c5 Cloud \u03c0\u03b1\u03c1\u03b1\u03ba\u03ac\u03c4\u03c9, \u03c0.\u03c7. *example-project-12345*. \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [Google Cloud Console]({cloud_console_url}) \u03ae \u03c3\u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7 \u03b3\u03b9\u03b1 [\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]({more_info_url}).", + "title": "Nest: \u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 Cloud" + }, + "create_cloud_project": { + "description": "\u0397 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 Nest \u03c3\u03ac\u03c2 \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b5\u03bd\u03c3\u03c9\u03bc\u03b1\u03c4\u03ce\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03c5\u03c2 \u03b8\u03b5\u03c1\u03bc\u03bf\u03c3\u03c4\u03ac\u03c4\u03b5\u03c2 Nest, \u03c4\u03b9\u03c2 \u03ba\u03ac\u03bc\u03b5\u03c1\u03b5\u03c2 \u03ba\u03b1\u03b9 \u03c4\u03b1 \u03ba\u03bf\u03c5\u03b4\u03bf\u03cd\u03bd\u03b9\u03b1 \u03c0\u03cc\u03c1\u03c4\u03b1\u03c2 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03ce\u03bd\u03c4\u03b1\u03c2 \u03c4\u03bf API \u03b4\u03b9\u03b1\u03c7\u03b5\u03af\u03c1\u03b9\u03c3\u03b7\u03c2 \u03ad\u03be\u03c5\u03c0\u03bd\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2. \u03a4\u03bf SDM API **\u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03bc\u03b9\u03b1 \u03b5\u03c6\u03ac\u03c0\u03b1\u03be \u03c7\u03c1\u03ad\u03c9\u03c3\u03b7 \u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2 5 $**. \u0394\u03b5\u03af\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7 \u03b3\u03b9\u03b1 [\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]( {more_info_url} ). \n\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03bf [Google Cloud Console]( {cloud_console_url} ).\n 1. \u0395\u03ac\u03bd \u03b1\u03c5\u03c4\u03cc \u03b5\u03af\u03bd\u03b1\u03b9 \u03c4\u03bf \u03c0\u03c1\u03ce\u03c4\u03bf \u03c3\u03b1\u03c2 \u03ad\u03c1\u03b3\u03bf, \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ad\u03c1\u03b3\u03bf\u03c5** \u03ba\u03b1\u03b9 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03bd\u03ad\u03c7\u03b5\u03b9\u03b1 \u03c3\u03c4\u03bf **\u039d\u03ad\u03bf \u03ad\u03c1\u03b3\u03bf**.\n 1. \u0394\u03ce\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c3\u03c4\u03bf Cloud Project \u03c3\u03b1\u03c2 \u03ba\u03b1\u03b9 \u03bc\u03b5\u03c4\u03ac \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1**.\n 1. \u0391\u03c0\u03bf\u03b8\u03b7\u03ba\u03b5\u03cd\u03c3\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 Cloud, \u03c0.\u03c7. *example-project-12345* \u03ba\u03b1\u03b8\u03ce\u03c2 \u03b8\u03b1 \u03c4\u03bf \u03c7\u03c1\u03b5\u03b9\u03b1\u03c3\u03c4\u03b5\u03af\u03c4\u03b5 \u03b1\u03c1\u03b3\u03cc\u03c4\u03b5\u03c1\u03b1\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7 \u0392\u03b9\u03b2\u03bb\u03b9\u03bf\u03b8\u03ae\u03ba\u03b7 API \u03b3\u03b9\u03b1 \u03c4\u03bf [Smart Device Management API]( {sdm_api_url} ) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7**.\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7 \u0392\u03b9\u03b2\u03bb\u03b9\u03bf\u03b8\u03ae\u03ba\u03b7 API \u03b3\u03b9\u03b1 \u03c4\u03bf [Cloud Pub/Sub API]( {pubsub_api_url} ) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7**. \n\n \u03a3\u03c5\u03bd\u03b5\u03c7\u03af\u03c3\u03c4\u03b5 \u03cc\u03c4\u03b1\u03bd \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af \u03c4\u03bf \u03ad\u03c1\u03b3\u03bf \u03c3\u03b1\u03c2 \u03c3\u03c4\u03bf cloud.", + "title": "Nest: \u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03ba\u03b1\u03b9 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf Cloud Project" + }, + "device_project": { + "data": { + "project_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "description": "\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ad\u03c1\u03b3\u03bf \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Nest Device Access, \u03c4\u03bf \u03bf\u03c0\u03bf\u03af\u03bf **\u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03ad\u03bd\u03b1 \u03c4\u03ad\u03bb\u03bf\u03c2 \u03cd\u03c8\u03bf\u03c5\u03c2 5 \u03b4\u03bf\u03bb\u03b1\u03c1\u03af\u03c9\u03bd \u0397\u03a0\u0391** \u03b3\u03b9\u03b1 \u03c4\u03b7 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03c4\u03bf\u03c5.\n1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [Device Access Console]({device_access_console_url}), \u03ba\u03b1\u03b9 \u03bc\u03ad\u03c3\u03c9 \u03c4\u03b7\u03c2 \u03c1\u03bf\u03ae\u03c2 \u03c0\u03bb\u03b7\u03c1\u03c9\u03bc\u03ae\u03c2.\n1. \u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ad\u03c1\u03b3\u03bf\u03c5**.\n1. \u0394\u03ce\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c3\u03c4\u03bf \u03ad\u03c1\u03b3\u03bf Device Access \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0395\u03c0\u03cc\u03bc\u03b5\u03bd\u03bf**.\n1. \u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth\n1. \u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b1 \u03c3\u03c5\u03bc\u03b2\u03ac\u03bd\u03c4\u03b1 \u03ba\u03ac\u03bd\u03bf\u03bd\u03c4\u03b1\u03c2 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae **\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7** \u03ba\u03b1\u03b9 **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ad\u03c1\u03b3\u03bf\u03c5**.\n\n\u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 \u03c3\u03b1\u03c2 \u03c0\u03b1\u03c1\u03b1\u03ba\u03ac\u03c4\u03c9 ([\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]({more_info_url})).\n", + "title": "Nest: \u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ad\u03c1\u03b3\u03bf \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "device_project_upgrade": { + "description": "\u0395\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf Nest Device Access Project \u03bc\u03b5 \u03c4\u03bf \u03bd\u03ad\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth ([\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]({more_info_url}))\n1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [Device Access Console]({device_access_console_url}).\n1. \u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03b5\u03b9\u03ba\u03bf\u03bd\u03af\u03b4\u03b9\u03bf \u03c4\u03bf\u03c5 \u03ba\u03ac\u03b4\u03bf\u03c5 \u03b1\u03c0\u03bf\u03c1\u03c1\u03b9\u03bc\u03bc\u03ac\u03c4\u03c9\u03bd \u03b4\u03af\u03c0\u03bb\u03b1 \u03c3\u03c4\u03bf *OAuth Client ID*.\n1. \u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03bc\u03b5\u03bd\u03bf\u03cd \u03c5\u03c0\u03b5\u03c1\u03c7\u03b5\u03af\u03bb\u03b9\u03c3\u03b7\u03c2 `...` \u03ba\u03b1\u03b9 *Add Client ID*.\n1. \u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03bd\u03ad\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u03a0\u03c1\u03bf\u03c3\u03b8\u03ae\u03ba\u03b7**.\n\n\u03a4\u03bf \u03b4\u03b9\u03ba\u03cc \u03c3\u03b1\u03c2 \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth \u03b5\u03af\u03bd\u03b1\u03b9: `{client_id}`", + "title": "Nest: \u0395\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7 \u03ad\u03c1\u03b3\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, "init": { "data": { "flow_impl": "\u03a0\u03ac\u03c1\u03bf\u03c7\u03bf\u03c2" diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 6376807302b..5f026e55f31 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." + }, "config": { "abort": { + "already_configured": "Account is already configured", "authorize_url_timeout": "Timeout generating authorize URL.", "invalid_access_token": "Invalid access token", "missing_configuration": "The component is not configured. Please follow the documentation.", @@ -19,7 +23,7 @@ "subscriber_error": "Unknown subscriber error, see logs", "timeout": "Timeout validating code", "unknown": "Unexpected error", - "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)" + "wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", "title": "Link Google Account" }, + "auth_upgrade": { + "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices.", + "title": "Nest: App Auth Deprecation" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).", + "title": "Nest: Enter Cloud Project ID" + }, + "create_cloud_project": { + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up.", + "title": "Nest: Create and configure Cloud Project" + }, + "device_project": { + "data": { + "project_id": "Device Access Project ID" + }, + "description": "Create a Nest Device Access project which **requires a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "title": "Nest: Create a Device Access Project" + }, + "device_project_upgrade": { + "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`", + "title": "Nest: Update Device Access Project" + }, "init": { "data": { "flow_impl": "Provider" diff --git a/homeassistant/components/nest/translations/et.json b/homeassistant/components/nest/translations/et.json index 898c9e9f3f3..b07845f7dab 100644 --- a/homeassistant/components/nest/translations/et.json +++ b/homeassistant/components/nest/translations/et.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "J\u00e4rgi pilvekonsooli seadistamiseks [juhiseid]({more_info_url}):\n\n1. Ava [OAuth n\u00f5usoleku kuva]({oauth_consent_url}) ja seadista\n1. Mine aadressile [Mandaat]({oauth_creds_url}) ja kl\u00f5psa nuppu **Loo mandaat**.\n1. Vali ripploendist **OAuth kliendi ID**.\n1. Vali rakenduse t\u00fc\u00fcbi jaoks **Veebirakendus**.\n1. Lisa \"{redirect_url}\" jaotises *Volitatud \u00fcmbersuunamine URI*." + }, "config": { "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", "authorize_url_timeout": "Tuvastamise URL-i loomise ajal\u00f5pp.", "invalid_access_token": "Vigane juurdep\u00e4\u00e4sut\u00f5end", "missing_configuration": "Osis pole seadistatud. Vaata dokumentatsiooni.", @@ -29,6 +33,32 @@ "description": "Oma Google'i konto sidumiseks vali [autoriseeri oma konto]({url}).\n\nP\u00e4rast autoriseerimist kopeeri ja aseta allpool esitatud Auth Token'i kood.", "title": "Google'i konto linkimine" }, + "auth_upgrade": { + "description": "Google on app Authi turvalisuse parandamiseks tauninud ja pead tegutsema, luues uusi rakenduse mandaate.\n\nAva [dokumentatsioon]({more_info_url}), mida j\u00e4rgida, kuna j\u00e4rgmised juhised juhendavad teid nest-seadmetele juurdep\u00e4\u00e4su taastamiseks vajalike juhiste kaudu.", + "title": "Nest: App Auth Deprecation" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google'i pilveprojekti ID" + }, + "description": "Sisesta allpool pilveprojekti ID nt *n\u00e4idisprojekt-12345*. Vaata [Google'i pilvekonsooli]({cloud_console_url}) v\u00f5i dokumentatsiooni [lisateave]({more_info_url}).", + "title": "Nest: sisesta pilveprojekti ID" + }, + "create_cloud_project": { + "description": "Nesti sidumine v\u00f5imaldab integreerida pesa termostaate, kaameraid ja uksekellasid nutiseadme haldamise API abil. SDM API **n\u00f5uab \u00fchekordset h\u00e4\u00e4lestustasu 5 USA dollarit**. Vt dokumentatsiooni teemast [more information]({more_info_url}).\n\n1. Mine [Google'i pilvekonsooli]({cloud_console_url}).\n1. Kui see on esimene projekt, kl\u00f5psa nuppu **Loo projekt** ja seej\u00e4rel **Uus projekt**.\n1. Anna oma pilveprojektile nimi ja seej\u00e4rel kl\u00f5psa nuppu **Loo**.\n1. Salvesta pilveprojekti ID nt *n\u00e4ide-projekt-12345*, kuna vajad seda hiljem\n1. Ava API teek [Smart Device Management API]({sdm_api_url}) jaoks ja kl\u00f5psa nuppu **Luba**.\n1. Mine API teeki [Cloud Pub/Sub API]({pubsub_api_url}) jaoks ja kl\u00f5psa nuppu **Luba**.\n\nJ\u00e4tka kui pilveprojekt on h\u00e4\u00e4lestatud.", + "title": "Nest: Pilveprojekti loomine ja konfigureerimine" + }, + "device_project": { + "data": { + "project_id": "Seadme juurdep\u00e4\u00e4su projekti ID" + }, + "description": "Loo Nest Device Accessi projekt, mille seadistamiseks on vaja 5 USA dollari suurust tasu**.\n1. Ava [Seadme juurdep\u00e4\u00e4sukonsool]({device_access_console_url}) ja maksevoo kaudu.\n1. Vajuta **Loo projekt**\n1. Anna oma seadmele juurdep\u00e4\u00e4su projektile nimi ja kl\u00f5psa nuppu **Next**.\n1. Sisesta oma OAuth Kliendi ID\n1. Luba s\u00fcndmused, kl\u00f5psates nuppu **Luba** ja **Loo projekt**.\n\nSisesta allpool seadme accessi projekti ID ([lisateave]({more_info_url})).\n", + "title": "Nest: seadmele juurdep\u00e4\u00e4su projekti loomine" + }, + "device_project_upgrade": { + "description": "Nest Device Access Projecti v\u00e4rskendamine uue OAuth Client ID-ga ([lisateave]({more_info_url}))\n1. Ava [Seadme juurdep\u00e4\u00e4sukonsool]({device_access_console_url}).\n1. Kl\u00f5psa pr\u00fcgikastiikooni *OAuth Client ID* k\u00f5rval.\n1. Kl\u00f5psa \u00fclet\u00e4itumise men\u00fc\u00fcd \"...\", ja *Lisa kliendi ID*.\n1. Sisesta uus OAuth-kliendi ID ja kl\u00f5psa nuppu **Lisa**.\n\nOAuth-kliendi ID on:{client_id}.", + "title": "Nest: seadmele juurdep\u00e4\u00e4su projekti v\u00e4rskendamine" + }, "init": { "data": { "flow_impl": "Pakkuja" diff --git a/homeassistant/components/nest/translations/fr.json b/homeassistant/components/nest/translations/fr.json index d639009dff8..16990b93193 100644 --- a/homeassistant/components/nest/translations/fr.json +++ b/homeassistant/components/nest/translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification expir\u00e9.", "invalid_access_token": "Jeton d'acc\u00e8s non valide", "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", @@ -19,7 +20,7 @@ "subscriber_error": "Erreur d'abonn\u00e9 inconnue, voir les journaux", "timeout": "D\u00e9lai de la validation du code expir\u00e9", "unknown": "Erreur inattendue", - "wrong_project_id": "Veuillez saisir un ID de projet Cloud valide (ID de projet d'acc\u00e8s \u00e0 l'appareil trouv\u00e9)" + "wrong_project_id": "Veuillez saisir un ID de projet Cloud valide (\u00e9tait identique \u00e0 l'ID de projet d'acc\u00e8s \u00e0 l'appareil)" }, "step": { "auth": { @@ -29,6 +30,27 @@ "description": "Pour lier votre compte Google, [autorisez votre compte]( {url} ). \n\n Apr\u00e8s autorisation, copiez-collez le code d'authentification fourni ci-dessous.", "title": "Associer un compte Google" }, + "auth_upgrade": { + "title": "Nest\u00a0: abandon de l'authentification d'application" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID de projet Google\u00a0Cloud" + }, + "title": "Nest\u00a0: saisissez l'ID du projet Cloud" + }, + "create_cloud_project": { + "title": "Nest\u00a0: cr\u00e9er et configurer un projet Cloud" + }, + "device_project": { + "data": { + "project_id": "ID de projet d'acc\u00e8s \u00e0 l'appareil" + }, + "title": "Nest\u00a0: cr\u00e9er un projet d'acc\u00e8s \u00e0 l'appareil" + }, + "device_project_upgrade": { + "title": "Nest\u00a0: mettre \u00e0 jour le projet d'acc\u00e8s \u00e0 l'appareil" + }, "init": { "data": { "flow_impl": "Fournisseur" diff --git a/homeassistant/components/nest/translations/hu.json b/homeassistant/components/nest/translations/hu.json index 1f98e162a7b..228f4f5e745 100644 --- a/homeassistant/components/nest/translations/hu.json +++ b/homeassistant/components/nest/translations/hu.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "K\u00f6vesse az [utas\u00edt\u00e1sokat]( {more_info_url} ) a Cloud Console konfigur\u00e1l\u00e1s\u00e1hoz: \n\n 1. Nyissa meg az [OAuth hozz\u00e1j\u00e1rul\u00e1si k\u00e9perny\u0151t]({oauth_consent_url}), \u00e9s \u00e1ll\u00edtsa be\n 1. Nyissa meg a [Hiteles\u00edt\u00e9si adatok]({oauth_creds_url}), majd kattintson a **Hiteles\u00edt\u0151 adatok l\u00e9trehoz\u00e1sa** lehet\u0151s\u00e9gre.\n 1. A leg\u00f6rd\u00fcl\u0151 list\u00e1b\u00f3l v\u00e1lassza az **OAuth-\u00fcgyf\u00e9lazonos\u00edt\u00f3** lehet\u0151s\u00e9get.\n 1. V\u00e1lassza a **Webes alkalmaz\u00e1s** lehet\u0151s\u00e9get az Alkalmaz\u00e1s t\u00edpusak\u00e9nt.\n 1. Adja hozz\u00e1 a `{redirect_url}` \u00e9rt\u00e9ket az *Authorized redirect URI* r\u00e9szhez." + }, "config": { "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", "invalid_access_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", @@ -19,7 +23,7 @@ "subscriber_error": "Ismeretlen el\u0151fizet\u0151i hiba, b\u0151vebben a napl\u00f3kban", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "wrong_project_id": "K\u00e9rem, adjon meg egy \u00e9rv\u00e9nyes Cloud Project ID-t (Device Access Project ID tal\u00e1lva)" + "wrong_project_id": "K\u00e9rem, adjon meg egy \u00e9rv\u00e9nyes Cloud Project ID-t (Device Access Project ID-vrl azonos)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "[Enged\u00e9lyezze]({url}) Google-fi\u00f3kj\u00e1t az \u00f6sszekapcsol\u00e1hoz.\n\nAz enged\u00e9lyez\u00e9s ut\u00e1n m\u00e1solja \u00e1t a kapott token k\u00f3dot.", "title": "\u00d6sszekapcsol\u00e1s Google-al" }, + "auth_upgrade": { + "description": "A Google a biztons\u00e1g jav\u00edt\u00e1sa \u00e9rdek\u00e9ben megsz\u00fcntette az App Auth szolg\u00e1ltat\u00e1st, \u00e9s \u00d6nnek \u00faj alkalmaz\u00e1s hiteles\u00edt\u00e9si adatainak l\u00e9trehoz\u00e1s\u00e1val kell tennie valamit. \n\n Nyissa meg a [dokument\u00e1ci\u00f3t]({more_info_url}), hogy k\u00f6vesse, mivel a k\u00f6vetkez\u0151 l\u00e9p\u00e9sek v\u00e9gigvezetik a Nest-eszk\u00f6zeihez val\u00f3 hozz\u00e1f\u00e9r\u00e9s vissza\u00e1ll\u00edt\u00e1s\u00e1hoz sz\u00fcks\u00e9ges l\u00e9p\u00e9seken.", + "title": "Nest: Az alkalmaz\u00e1shiteles\u00edt\u00e9s megsz\u00fcntet\u00e9se" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Adja meg a Cloud projekt azonos\u00edt\u00f3j\u00e1t, pl. *example-project-12345*. B\u0151vebben: [Google Cloud Console]({cloud_console_url}) vagy [dokument\u00e1ci\u00f3]({more_info_url}).", + "title": "Nest: Adja meg a Cloud Project ID-t" + }, + "create_cloud_project": { + "description": "A Nest-integr\u00e1ci\u00f3 lehet\u0151v\u00e9 teszi Nest termoszt\u00e1tjainak, kamer\u00e1inak \u00e9s ajt\u00f3cseng\u0151inek integr\u00e1l\u00e1s\u00e1t a Smart Device Management API seg\u00edts\u00e9g\u00e9vel. Az SDM API **5 USD** egyszeri be\u00e1ll\u00edt\u00e1si d\u00edjat ig\u00e9nyel. [Tov\u00e1bbi inform\u00e1ci\u00f3]({more_info_url}). \n\n 1. Nyissa meg a [Google Cloud Console]({cloud_console_url}) oldalt.\n 1. Ha ez az els\u0151 projektje, kattintson a **Projekt l\u00e9trehoz\u00e1sa**, majd az **\u00daj projekt** lehet\u0151s\u00e9gre.\n 1. Adjon nevet a Cloud Projectnek, majd kattintson a **L\u00e9trehoz\u00e1s** gombra.\n 1. Mentse el a felh\u0151projekt azonos\u00edt\u00f3j\u00e1t, p\u00e9ld\u00e1ul *example-projekt-12345*, mert k\u00e9s\u0151bb sz\u00fcks\u00e9ge lesz r\u00e1\n 1. Nyissa meg a [Smart Device Management API-t]({sdm_api_url}), \u00e9s kattintson az **Enged\u00e9lyez\u00e9s** lehet\u0151s\u00e9gre.\n 1. Nyissa meg a [Cloud Pub/Sub API-t]({pubsub_api_url}), \u00e9s kattintson az **Enged\u00e9lyez\u00e9s** lehet\u0151s\u00e9gre. \n\nFolytassa a be\u00e1ll\u00edt\u00e1s ut\u00e1n.", + "title": "Nest: Cloud Project l\u00e9trehoz\u00e1sa \u00e9s konfigur\u00e1l\u00e1sa" + }, + "device_project": { + "data": { + "project_id": "Eszk\u00f6z-hozz\u00e1f\u00e9r\u00e9s Projekt azonos\u00edt\u00f3" + }, + "description": "Hozzon l\u00e9tre egy Nest Device Access projektet, amelynek **be\u00e1ll\u00edt\u00e1sa 5 USD d\u00edjat** ig\u00e9nyel.\n1. Menjen a [Device Access Console]({device_access_console_url}) oldalra, \u00e9s a fizet\u00e9si folyamaton kereszt\u00fcl.\n1. Kattintson a **Projekt l\u00e9trehoz\u00e1sa** gombra.\n1. Adjon nevet a Device Access projektnek, \u00e9s kattintson a **K\u00f6vetkez\u0151** gombra.\n1. Adja meg az OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3j\u00e1t\n1. Enged\u00e9lyezze az esem\u00e9nyeket a **Enable** \u00e9s a **Create project** gombra kattintva.\n\nAdja meg a Device Access projekt azonos\u00edt\u00f3j\u00e1t az al\u00e1bbiakban ([more info]({more_info_url})).\n", + "title": "Nest: Hozzon l\u00e9tre egy eszk\u00f6z-hozz\u00e1f\u00e9r\u00e9si projektet" + }, + "device_project_upgrade": { + "description": "Friss\u00edtse a Nest Device Access projektet az \u00faj OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3j\u00e1val ([more info]({more_info_url}))\n1. L\u00e9pjen az [Device Access Console]({device_access_console_url}).\n1. Kattintson a *OAuth Client ID* melletti szemetes ikonra.\n1. Kattintson a `...` t\u00falfoly\u00f3 men\u00fcre \u00e9s a *Add Client ID* men\u00fcpontra.\n1. Adja meg az \u00faj OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3j\u00e1t, \u00e9s kattintson a **Add** gombra.\n\nAz \u00d6n OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3ja a k\u00f6vetkez\u0151: `{client_id}`", + "title": "Nest: Friss\u00edtse az eszk\u00f6zhozz\u00e1f\u00e9r\u00e9si projektet" + }, "init": { "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" diff --git a/homeassistant/components/nest/translations/id.json b/homeassistant/components/nest/translations/id.json index cc7cdc600a8..6a45ccaee8c 100644 --- a/homeassistant/components/nest/translations/id.json +++ b/homeassistant/components/nest/translations/id.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Ikuti [petunjuk]({more_info_url}) untuk mengonfigurasi Konsol Cloud:\n\n1. Buka [Layar persetujuan OAuth]({oauth_consent_url}) dan konfigurasikan\n1. Buka [Kredentsial]({oauth_creds_url}) dan klik **Buat Kredensial**.\n1. Dari daftar pilihan, pilih **ID klien OAuth**.\n1. Pilih **Aplikasi Web** untuk Jenis Aplikasi.\n1. Tambahkan '{redirect_url}' di bawah *URI pengarahan ulang yand diotorisasi*." + }, "config": { "abort": { + "already_configured": "Akun sudah dikonfigurasi", "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", "invalid_access_token": "Token akses tidak valid", "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", @@ -19,7 +23,7 @@ "subscriber_error": "Kesalahan pelanggan tidak diketahui, lihat log", "timeout": "Tenggang waktu memvalidasi kode telah habis.", "unknown": "Kesalahan yang tidak diharapkan", - "wrong_project_id": "Masukkan Cloud Project ID yang valid (Device Access Project ID yang ditemukan)" + "wrong_project_id": "Masukkan ID Proyek Cloud yang valid (sebelumnya sama dengan ID Proyek Akses Perangkat)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "Untuk menautkan akun Google Anda, [otorisasi akun Anda]({url}).\n\nSetelah otorisasi, salin dan tempel Token Auth yang disediakan di bawah ini.", "title": "Tautkan Akun Google" }, + "auth_upgrade": { + "description": "Autentikasi Aplikasi tidak digunakan lagi oleh Google untuk meningkatkan keamanan, dan Anda perlu mengambil tindakan dengan membuat kredensial aplikasi baru. \n\nBuka [dokumentasi]({more_info_url}) untuk panduan langkah selanjutnya yang perlu diambil untuk memulihkan akses ke perangkat Nest Anda.", + "title": "Nest: Penghentian Autentikasi Aplikasi" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID Proyek Google Cloud" + }, + "description": "Masukkan ID Proyek Cloud di bawah ini, misalnya *contoh-proyek-12345*. Lihat [Konsol Google Cloud]({cloud_console_url}) atau dokumentasi untuk [info selengkapnya]({more_info_url}).", + "title": "Nest: Masukkan ID Proyek Cloud" + }, + "create_cloud_project": { + "description": "Integrasi Nest memungkinkan Anda mengintegrasikan Nest Thermostat, Kamera, dan Bel Pintu menggunakan API Smart Device Management. API SDM **memerlukan biaya penyiapan satu kali sebesar USD5**. Lihat dokumentasi untuk [info lebih lanjut]({more_info_url}). \n\n1. Buka [Konsol Google Cloud]({cloud_console_url}).\n1. Jika ini adalah proyek pertama Anda, klik **Buat Proyek** lalu **Proyek Baru**.\n1. Beri Nama Proyek Cloud Anda, lalu klik **Buat**.\n1. Simpan ID Proyek Cloud misalnya *example-project-12345* karena Anda akan membutuhkannya nanti\n1. Buka Perpustakaan API untuk [API Smart Device Management]({sdm_api_url}) dan klik **Aktifkan**.\n1. Buka Perpustakaan API untuk [API Cloud Pub/Sub]({pubsub_api_url}) dan klik **Aktifkan**. \n\n Lanjutkan saat proyek cloud Anda sudah disiapkan.", + "title": "Nest: Buat dan konfigurasikan Proyek Cloud" + }, + "device_project": { + "data": { + "project_id": "ID Proyek Akses Perangkat" + }, + "description": "Buat proyek Akses Perangkat Nest yang **membutuhkan biaya USD5** untuk menyiapkannya.\n1. Buka [Konsol Akses Perangkat]({device_access_console_url}), dan ikuti alur pembayaran.\n1. Klik **Buat proyek**\n1. Beri nama proyek Akses Perangkat Anda dan klik **Berikutnya**.\n1. Masukkan ID Klien OAuth Anda\n1. Aktifkan acara dengan mengklik **Aktifkan** dan **Buat proyek**. \n\n Masukkan ID Proyek Akses Perangkat Anda di bawah ini ([more info]({more_info_url})).\n", + "title": "Nest: Buat Proyek Akses Perangkat" + }, + "device_project_upgrade": { + "description": "Perbarui Proyek Akses Perangkat Nest dengan ID Klien OAuth baru Anda ([info lebih lanjut]({more_info_url}))\n1. Buka [Konsol Akses Perangkat]( {device_access_console_url} ).\n1. Klik ikon tempat sampah di samping *ID Klien OAuth*.\n1. Klik menu luapan `...` dan *Tambah ID Klien*.\n1. Masukkan ID Klien OAuth baru Anda dan klik **Tambah**. \n\n ID Klien OAuth Anda adalah: `{client_id}`", + "title": "Nest: Perbarui Proyek Akses Perangkat" + }, "init": { "data": { "flow_impl": "Penyedia" diff --git a/homeassistant/components/nest/translations/it.json b/homeassistant/components/nest/translations/it.json index 5942414bcf3..8979631dec0 100644 --- a/homeassistant/components/nest/translations/it.json +++ b/homeassistant/components/nest/translations/it.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Segui le [istruzioni]({more_info_url}) per configurare la Cloud Console: \n\n 1. Vai alla [schermata di consenso OAuth]({oauth_consent_url}) e configura\n 2. Vai a [Credenziali]({oauth_creds_url}) e fai clic su **Crea credenziali**.\n 3. Dall'elenco a discesa selezionare **ID client OAuth**.\n 4. Selezionare **Applicazione Web** come Tipo di applicazione.\n 5. Aggiungi `{redirect_url}` sotto *URI di reindirizzamento autorizzato*." + }, "config": { "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.", "invalid_access_token": "Token di accesso non valido", "missing_configuration": "Il componente non \u00e8 configurato. Segui la documentazione.", @@ -19,7 +23,7 @@ "subscriber_error": "Errore di abbonato sconosciuto, vedere i registri", "timeout": "Tempo scaduto per l'inserimento del codice di convalida", "unknown": "Errore imprevisto", - "wrong_project_id": "Inserisci un ID di progetto Cloud valido (trovato ID di progetto di accesso al dispositivo)" + "wrong_project_id": "Inserisci un ID progetto cloud valido (uguale all'ID progetto di accesso al dispositivo)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "Per collegare l'account Google, [authorize your account]({url}).\n\nDopo l'autorizzazione, copia-incolla il codice PIN fornito.", "title": "Connetti l'account Google" }, + "auth_upgrade": { + "description": "App Auth \u00e8 stato ritirato da Google per migliorare la sicurezza ed \u00e8 necessario intervenire creando nuove credenziali per l'applicazione. \n\nApri la [documentazione]({more_info_url}) per seguire i passaggi successivi che ti guideranno attraverso gli stadi necessari per ripristinare l'accesso ai tuoi dispositivi Nest.", + "title": "Nest: ritiro dell'autenticazione dell'app" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID progetto Google Cloud" + }, + "description": "Immetti l'ID progetto cloud di seguito, ad esempio *example-project-12345*. Consulta la [Google Cloud Console]({cloud_console_url}) o la documentazione per [maggiori informazioni]({more_info_url}).", + "title": "Nest: inserisci l'ID del progetto Cloud" + }, + "create_cloud_project": { + "description": "L'integrazione Nest ti consente di integrare i tuoi termostati, videocamere e campanelli Nest utilizzando l'API Smart Device Management. L'API SDM **richiede una tariffa di configurazione una tantum di 5 $ USD**. Consulta la documentazione per [maggiori informazioni]({more_info_url}). \n\n 1. Vai su [Google Cloud Console]({cloud_console_url}).\n 2. Se questo \u00e8 il tuo primo progetto, fai clic su **Crea progetto** e poi su **Nuovo progetto**.\n 3. Assegna un nome al tuo progetto cloud, quindi fai clic su **Crea**.\n 4. Salva l'ID del progetto cloud, ad es. *example-project-12345*, poich\u00e9 ti servir\u00e0 in seguito.\n 5. Vai su Libreria API per [API Smart Device Management]({sdm_api_url}) e fai clic su **Abilita**.\n 6. Vai su Libreria API per [Cloud Pub/Sub API]({pubsub_api_url}) e fai clic su **Abilita**. \n\nProcedi quando il tuo progetto cloud \u00e8 impostato.", + "title": "Nest: crea e configura un progetto cloud" + }, + "device_project": { + "data": { + "project_id": "ID progetto di accesso al dispositivo" + }, + "description": "Crea un progetto di accesso al dispositivo Nest la cui configurazione **richiede una commissione di 5 $ USD**.\n 1. Vai alla [Console di accesso al dispositivo]({device_access_console_url}) e attraverso il flusso di pagamento.\n 2. Clicca su **Crea progetto**\n 3. Assegna un nome al progetto di accesso al dispositivo e fai clic su **Avanti**.\n 4. Inserisci il tuo ID Client OAuth\n 5. Abilita gli eventi facendo clic su **Abilita** e **Crea progetto**. \n\nInserisci il tuo ID progetto di accesso al dispositivo di seguito ([maggiori informazioni]({more_info_url})).\n", + "title": "Nest: crea un progetto di accesso al dispositivo" + }, + "device_project_upgrade": { + "description": "Aggiorna il progetto di accesso al dispositivo Nest con il nuovo ID client OAuth ([ulteriori informazioni]({more_info_url}))\n1. Vai a [Console di accesso al dispositivo]({device_access_console_url}).\n2. Fai clic sull'icona del cestino accanto a *ID Client OAuth*.\n3. Fai clic sul menu a comparsa '...' e *Aggiungi ID Client*.\n4. Immettere il nuovo ID Client OAuth e fare clic su **Aggiungi**.\n\nIl tuo ID Client OAuth \u00e8: `{client_id}`", + "title": "Nest: aggiorna il progetto di accesso al dispositivo" + }, "init": { "data": { "flow_impl": "Provider" diff --git a/homeassistant/components/nest/translations/ja.json b/homeassistant/components/nest/translations/ja.json index c2994de0532..ee9fcbba98d 100644 --- a/homeassistant/components/nest/translations/ja.json +++ b/homeassistant/components/nest/translations/ja.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", @@ -29,6 +30,27 @@ "description": "Google\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b\u306b\u306f\u3001 [authorize your account]({url}) \u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u8a8d\u8a3c\u5f8c\u3001\u63d0\u4f9b\u3055\u308c\u305f\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u306e\u30b3\u30fc\u30c9\u3092\u4ee5\u4e0b\u306b\u30b3\u30d4\u30fc\u30da\u30fc\u30b9\u30c8\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "title": "Google\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b" }, + "auth_upgrade": { + "title": "\u30cd\u30b9\u30c8: \u30a2\u30d7\u30ea\u8a8d\u8a3c\u306e\u975e\u63a8\u5968" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID" + }, + "title": "\u30cd\u30b9\u30c8: \u30af\u30e9\u30a6\u30c9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID\u3092\u5165\u529b" + }, + "create_cloud_project": { + "title": "\u30cd\u30b9\u30c8: \u30af\u30e9\u30a6\u30c9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u4f5c\u6210\u3068\u8a2d\u5b9a" + }, + "device_project": { + "data": { + "project_id": "\u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID" + }, + "title": "\u30cd\u30b9\u30c8: \u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u4f5c\u6210" + }, + "device_project_upgrade": { + "title": "\u30cd\u30b9\u30c8: \u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u66f4\u65b0" + }, "init": { "data": { "flow_impl": "\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc" diff --git a/homeassistant/components/nest/translations/nl.json b/homeassistant/components/nest/translations/nl.json index a2f8ba77d78..8f5e0db9900 100644 --- a/homeassistant/components/nest/translations/nl.json +++ b/homeassistant/components/nest/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Account is al geconfigureerd", "authorize_url_timeout": "Time-out bij het genereren van autorisatie-URL.", "invalid_access_token": "Ongeldig toegangstoken", "missing_configuration": "Integratie niet geconfigureerd. Raadpleeg de documentatie.", diff --git a/homeassistant/components/nest/translations/no.json b/homeassistant/components/nest/translations/no.json index fcc6aeadddf..ce6663ec22e 100644 --- a/homeassistant/components/nest/translations/no.json +++ b/homeassistant/components/nest/translations/no.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "F\u00f8lg [instruksjonene]( {more_info_url} ) for \u00e5 konfigurere Cloud Console: \n\n 1. G\u00e5 til [OAuth-samtykkeskjermen]( {oauth_consent_url} ) og konfigurer\n 1. G\u00e5 til [Credentials]( {oauth_creds_url} ) og klikk p\u00e5 **Create Credentials**.\n 1. Velg **OAuth-klient-ID** fra rullegardinlisten.\n 1. Velg **Nettapplikasjon** for applikasjonstype.\n 1. Legg til ` {redirect_url} ` under *Autorisert omdirigerings-URI*." + }, "config": { "abort": { + "already_configured": "Kontoen er allerede konfigurert", "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", "invalid_access_token": "Ugyldig tilgangstoken", "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", @@ -19,7 +23,7 @@ "subscriber_error": "Ukjent abonnentfeil, se logger", "timeout": "Tidsavbrudd ved validering av kode", "unknown": "Uventet feil", - "wrong_project_id": "Angi en gyldig Cloud Project ID (funnet Device Access Project ID)" + "wrong_project_id": "Angi en gyldig Cloud Project ID (var den samme som Device Access Project ID)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "For \u00e5 koble til Google-kontoen din, [autoriser kontoen din]( {url} ). \n\n Etter autorisasjon, kopier og lim inn den oppgitte Auth Token-koden nedenfor.", "title": "Koble til Google-kontoen" }, + "auth_upgrade": { + "description": "App Auth har blitt avviklet av Google for \u00e5 forbedre sikkerheten, og du m\u00e5 iverksette tiltak ved \u00e5 opprette ny applikasjonslegitimasjon. \n\n \u00c5pne [dokumentasjonen]( {more_info_url} ) for \u00e5 f\u00f8lge med, da de neste trinnene vil lede deg gjennom trinnene du m\u00e5 ta for \u00e5 gjenopprette tilgangen til Nest-enhetene dine.", + "title": "Nest: Appautentisering avvikelse" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Skriv inn Cloud Project ID nedenfor, f.eks. *example-project-12345*. Se [Google Cloud Console]( {cloud_console_url} ) eller dokumentasjonen for [mer info]( {more_info_url} ).", + "title": "Nest: Angi Cloud Project ID" + }, + "create_cloud_project": { + "description": "Nest-integreringen lar deg integrere Nest-termostatene, kameraene og d\u00f8rklokkene dine ved hjelp av Smart Device Management API. SDM API **krever en engangsavgift p\u00e5 5 USD**. Se dokumentasjonen for [mer info]( {more_info_url} ). \n\n 1. G\u00e5 til [Google Cloud Console]( {cloud_console_url} ).\n 1. Hvis dette er ditt f\u00f8rste prosjekt, klikker du **Opprett prosjekt** og deretter **Nytt prosjekt**.\n 1. Gi skyprosjektet ditt et navn, og klikk deretter p\u00e5 **Opprett**.\n 1. Lagre Cloud Project ID, f.eks. *example-project-12345*, slik du trenger den senere\n 1. G\u00e5 til API Library for [Smart Device Management API]( {sdm_api_url} ) og klikk p\u00e5 **Aktiver**.\n 1. G\u00e5 til API-biblioteket for [Cloud Pub/Sub API]( {pubsub_api_url} ) og klikk p\u00e5 **Aktiver**. \n\n Fortsett n\u00e5r skyprosjektet ditt er satt opp.", + "title": "Nest: Opprett og konfigurer Cloud Project" + }, + "device_project": { + "data": { + "project_id": "Prosjekt-ID for enhetstilgang" + }, + "description": "Opprett et Nest Device Access-prosjekt som **krever en avgift p\u00e5 USD 5** for \u00e5 konfigurere.\n 1. G\u00e5 til [Device Access Console]( {device_access_console_url} ), og gjennom betalingsflyten.\n 1. Klikk p\u00e5 **Opprett prosjekt**\n 1. Gi Device Access-prosjektet ditt et navn og klikk p\u00e5 **Neste**.\n 1. Skriv inn din OAuth-klient-ID\n 1. Aktiver hendelser ved \u00e5 klikke **Aktiver** og **Opprett prosjekt**. \n\n Skriv inn Device Access Project ID nedenfor ([mer info]( {more_info_url} )).\n", + "title": "Nest: Opprett et Device Access Project" + }, + "device_project_upgrade": { + "description": "Oppdater Nest Device Access Project med din nye OAuth-klient-ID ([mer info]( {more_info_url} ))\n 1. G\u00e5 til [Device Access Console]( {device_access_console_url} ).\n 1. Klikk p\u00e5 s\u00f8ppelikonet ved siden av *OAuth Client ID*.\n 1. Klikk p\u00e5 \"...\" overl\u00f8psmenyen og *Legg til klient-ID*.\n 1. Skriv inn din nye OAuth-klient-ID og klikk p\u00e5 **Legg til**. \n\n Din OAuth-klient-ID er: ` {client_id} `", + "title": "Nest: Oppdater Device Access Project" + }, "init": { "data": { "flow_impl": "Tilbyder" diff --git a/homeassistant/components/nest/translations/pl.json b/homeassistant/components/nest/translations/pl.json index 5e9b9895db4..c7a5c88b120 100644 --- a/homeassistant/components/nest/translations/pl.json +++ b/homeassistant/components/nest/translations/pl.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Post\u0119puj zgodnie z [instrukcj\u0105]({more_info_url}), aby skonfigurowa\u0107 Cloud Console: \n\n1. Przejd\u017a do [ekranu akceptacji OAuth]({oauth_consent_url}) i skonfiguruj.\n2. Przejd\u017a do [Po\u015bwiadczenia]({oauth_creds_url}) i kliknij **Utw\u00f3rz po\u015bwiadczenia**.\n3. Z listy rozwijanej wybierz **Identyfikator klienta OAuth**.\n4. Wybierz **Aplikacja internetowa** jako Typ aplikacji.\n5. Dodaj `{redirect_url}` pod *Autoryzowany URI przekierowania*." + }, "config": { "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji", "invalid_access_token": "Niepoprawny token dost\u0119pu", "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", @@ -19,7 +23,7 @@ "subscriber_error": "Nieznany b\u0142\u0105d subskrybenta, zobacz logi", "timeout": "Przekroczono limit czasu sprawdzania poprawno\u015bci kodu", "unknown": "Nieoczekiwany b\u0142\u0105d", - "wrong_project_id": "Podaj prawid\u0142owy Identyfikator projektu chmury (znaleziono identyfikator projektu dost\u0119pu do urz\u0105dzenia)" + "wrong_project_id": "Podaj prawid\u0142owy Identyfikator projektu chmury (taki sam jak identyfikator projektu dost\u0119pu do urz\u0105dzenia)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "Aby po\u0142\u0105czy\u0107 swoje konto Google, [authorize your account]({url}). \n\nPo autoryzacji skopiuj i wklej podany poni\u017cej token uwierzytelniaj\u0105cy.", "title": "Po\u0142\u0105czenie z kontem Google" }, + "auth_upgrade": { + "description": "App Auth zosta\u0142o wycofane przez Google w celu poprawy bezpiecze\u0144stwa i musisz podj\u0105\u0107 dzia\u0142ania, tworz\u0105c nowe dane logowania do aplikacji. \n\nOtw\u00f3rz [dokumentacj\u0119]({more_info_url}), aby przej\u015b\u0107 dalej. Kolejne kroki poprowadz\u0105 Ci\u0119 przez instrukcje, kt\u00f3re musisz wykona\u0107, aby przywr\u00f3ci\u0107 dost\u0119p do urz\u0105dze\u0144 Nest.", + "title": "Nest: wycofanie App Auth" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Identyfikator projektu chmury Google" + }, + "description": "Wpisz poni\u017cej identyfikator projektu chmury, np. *przykladowy-projekt-12345*. Zajrzyj do [Konsoli Google Cloud]({cloud_console_url}) lub dokumentacji po [wi\u0119cej informacji]({more_info_url}).", + "title": "Nest: Wprowad\u017a identyfikator projektu chmury" + }, + "create_cloud_project": { + "description": "Integracja Nest umo\u017cliwia integracj\u0119 termostat\u00f3w, kamer i dzwonk\u00f3w Nest za pomoc\u0105 Smart Device Management API. Interfejs API SDM **wymaga jednorazowej op\u0142aty instalacyjnej w wysoko\u015bci 5 dolar\u00f3w**. Zajrzyj do dokumentacji po [wi\u0119cej informacji]({more_info_url}). \n\n1. Przejd\u017a do [Konsoli Google Cloud]({cloud_console_url}).\n2. Je\u015bli to Tw\u00f3j pierwszy projekt, kliknij **Utw\u00f3rz projekt**, a nast\u0119pnie **Nowy projekt**.\n3. Nadaj nazw\u0119 swojemu projektowi w chmurze, a nast\u0119pnie kliknij **Utw\u00f3rz**.\n4. Zapisz identyfikator projektu chmury, np. *przykladowy-projekt-12345*, poniewa\u017c b\u0119dziesz go potrzebowa\u0107 p\u00f3\u017aniej.\n5. Przejd\u017a do biblioteki API dla [Smart Device Management API]({sdm_api_url}) i kliknij **W\u0142\u0105cz**.\n6. Przejd\u017a do biblioteki API dla [Cloud Pub/Sub API]({pubsub_api_url}) i kliknij **W\u0142\u0105cz**. \n\nKontynuuj po skonfigurowaniu projektu w chmurze.", + "title": "Nest: Utw\u00f3rz i skonfiguruj projekt w chmurze" + }, + "device_project": { + "data": { + "project_id": "Identyfikator projektu dost\u0119pu do urz\u0105dzenia" + }, + "description": "Utw\u00f3rz projekt dost\u0119pu do urz\u0105dzenia Nest, kt\u00f3rego konfiguracja **wymaga op\u0142aty w wysoko\u015bci 5 dolar\u00f3w**.\n1. Przejd\u017a do [Konsoli dost\u0119pu do urz\u0105dzenia]({device_access_console_url}) i przejd\u017a przez proces p\u0142atno\u015bci.\n2. Kliknij **Utw\u00f3rz projekt**.\n3. Nadaj projektowi dost\u0119pu do urz\u0105dzenia nazw\u0119 i kliknij **Dalej**.\n4. Wprowad\u017a sw\u00f3j identyfikator klienta OAuth\n5. W\u0142\u0105cz wydarzenia, klikaj\u0105c **W\u0142\u0105cz** i **Utw\u00f3rz projekt**. \n\nPod ([wi\u0119cej informacji]({more_info_url})) wpisz sw\u00f3j identyfikator projektu dost\u0119pu do urz\u0105dzenia.\n", + "title": "Nest: Utw\u00f3rz projekt dost\u0119pu do urz\u0105dzenia" + }, + "device_project_upgrade": { + "description": "Zaktualizuj projekt dost\u0119pu do urz\u0105dzenia Nest przy u\u017cyciu nowego identyfikatora klienta OAuth ([wi\u0119cej informacji]({more_info_url}))\n1. Przejd\u017a do [Konsoli dost\u0119pu do urz\u0105dzenia]({device_access_console_url}).\n2. Kliknij ikon\u0119 kosza obok *Identyfikator klienta OAuth*.\n3. Kliknij menu rozszerzone `...` i *Dodaj identyfikator klienta*.\n4. Wprowad\u017a nowy identyfikator klienta OAuth i kliknij **Dodaj**. \n\nTw\u00f3j identyfikator klienta OAuth to: `{client_id}`", + "title": "Nest: Zaktualizuj projekt dost\u0119pu do urz\u0105dzenia" + }, "init": { "data": { "flow_impl": "Dostawca" diff --git a/homeassistant/components/nest/translations/pt-BR.json b/homeassistant/components/nest/translations/pt-BR.json index 54b558493b8..d96387aee82 100644 --- a/homeassistant/components/nest/translations/pt-BR.json +++ b/homeassistant/components/nest/translations/pt-BR.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Siga as [instru\u00e7\u00f5es]( {more_info_url} ) para configurar o Console da nuvem: \n\n 1. V\u00e1 para a [tela de consentimento OAuth]( {oauth_consent_url} ) e configure.\n 1. Acesse [Credentials]( {oauth_creds_url} ) e clique em **Create Credentials**.\n 1. Na lista suspensa, selecione **ID do cliente OAuth**.\n 1. Selecione **Aplicativo Web** para o Tipo de aplicativo.\n 1. Adicione ` {redirect_url} ` em *URI de redirecionamento autorizado*." + }, "config": { "abort": { + "already_configured": "Conta j\u00e1 configurada", "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", "invalid_access_token": "Token de acesso inv\u00e1lido", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", @@ -19,7 +23,7 @@ "subscriber_error": "Erro de assinante desconhecido, veja os logs", "timeout": "Excedido tempo limite para validar c\u00f3digo", "unknown": "Erro inesperado", - "wrong_project_id": "Insira um ID de projeto do Cloud v\u00e1lido (ID do projeto de acesso ao dispositivo encontrado)" + "wrong_project_id": "Insira um ID de projeto da nuvem v\u00e1lido (\u00e9 o mesmo que o ID do projeto de acesso ao dispositivo)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "Para vincular sua conta do Google, [autorize sua conta]( {url} ). \n\n Ap\u00f3s a autoriza\u00e7\u00e3o, copie e cole o c\u00f3digo de token de autentica\u00e7\u00e3o fornecido abaixo.", "title": "Vincular Conta do Google" }, + "auth_upgrade": { + "description": "A autentica\u00e7\u00e3o de aplicativo foi preterida pelo Google para melhorar a seguran\u00e7a, e voc\u00ea precisa agir criando novas credenciais de aplicativo. \n\n Abra a [documenta\u00e7\u00e3o]( {more_info_url} ) para acompanhar, pois as pr\u00f3ximas etapas o guiar\u00e3o pelas etapas necess\u00e1rias para restaurar o acesso aos seus dispositivos Nest.", + "title": "Nest: suspens\u00e3o de uso da autentica\u00e7\u00e3o do aplicativo" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID do projeto do Google Cloud" + }, + "description": "Insira o ID do projeto da nuvem abaixo, por exemplo, *example-project-12345*. Consulte o [Google Cloud Console]( {cloud_console_url} ) ou a documenta\u00e7\u00e3o para [mais informa\u00e7\u00f5es]( {more_info_url} ).", + "title": "Nest: Insira o ID do projeto da nuvem" + }, + "create_cloud_project": { + "description": "A integra\u00e7\u00e3o Nest permite que voc\u00ea integre seus termostatos, c\u00e2meras e campainhas Nest usando a API de gerenciamento de dispositivos inteligentes. A API SDM **requer uma taxa de configura\u00e7\u00e3o \u00fanica de US$ 5**. Consulte a documenta\u00e7\u00e3o para [mais informa\u00e7\u00f5es]( {more_info_url} ). \n\n 1. Acesse o [Console do Google Cloud]( {cloud_console_url} ).\n 1. Se este for seu primeiro projeto, clique em **Criar Projeto** e depois em **Novo Projeto**.\n 1. D\u00ea um nome ao seu projeto na nuvem e clique em **Criar**.\n 1. Salve o ID do projeto da nuvem, por exemplo, *example-project-12345*, pois voc\u00ea precisar\u00e1 dele mais tarde.\n 1. Acesse a Biblioteca de API para [Smart Device Management API]( {sdm_api_url} ) e clique em **Ativar**.\n 1. Acesse a API Library for [Cloud Pub/Sub API]( {pubsub_api_url} ) e clique em **Ativar**. \n\n Prossiga quando seu projeto de nuvem estiver configurado.", + "title": "Nest: criar e configurar o projeto de nuvem" + }, + "device_project": { + "data": { + "project_id": "C\u00f3digo do projeto de acesso ao dispositivo" + }, + "description": "Crie um projeto Nest Device Access que **exija uma taxa de US$ 5** para ser configurado.\n 1. V\u00e1 para o [Device Access Console]( {device_access_console_url} ) e atrav\u00e9s do fluxo de pagamento.\n 1. Clique em **Criar projeto**\n 1. D\u00ea um nome ao seu projeto Device Access e clique em **Pr\u00f3ximo**.\n 1. Insira seu ID do cliente OAuth\n 1. Ative os eventos clicando em **Ativar** e **Criar projeto**. \n\n Insira o ID do projeto de acesso ao dispositivo abaixo ([mais informa\u00e7\u00f5es]( {more_info_url} )).", + "title": "Nest: criar um projeto de acesso ao dispositivo" + }, + "device_project_upgrade": { + "description": "Atualize o Nest Device Access Project com seu novo ID do cliente OAuth ([mais informa\u00e7\u00f5es]( {more_info_url} ))\n 1. V\u00e1 para o [Console de acesso ao dispositivo]( {device_access_console_url} ).\n 1. Clique no \u00edcone da lixeira ao lado de *ID do cliente OAuth*.\n 1. Clique no menu flutuante `...` e *Adicionar ID do cliente*.\n 1. Insira seu novo ID do cliente OAuth e clique em **Adicionar**. \n\n Seu ID do cliente OAuth \u00e9: ` {client_id} `", + "title": "Nest: Atualizar projeto de acesso ao dispositivo" + }, "init": { "data": { "flow_impl": "Provedor" diff --git a/homeassistant/components/nest/translations/sv.json b/homeassistant/components/nest/translations/sv.json index d929451e504..9e91f3ddf94 100644 --- a/homeassistant/components/nest/translations/sv.json +++ b/homeassistant/components/nest/translations/sv.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Konto har redan konfigurerats", "authorize_url_timeout": "Timeout vid generering av en autentisieringsadress." }, "error": { @@ -27,7 +28,8 @@ }, "device_automation": { "trigger_type": { - "camera_motion": "R\u00f6relse uppt\u00e4ckt" + "camera_motion": "R\u00f6relse uppt\u00e4ckt", + "camera_person": "Person detekterad" } } } \ No newline at end of file diff --git a/homeassistant/components/nest/translations/tr.json b/homeassistant/components/nest/translations/tr.json index f39b0fc935e..1732443184e 100644 --- a/homeassistant/components/nest/translations/tr.json +++ b/homeassistant/components/nest/translations/tr.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Bulut Konsolunu yap\u0131land\u0131rmak i\u00e7in [talimatlar\u0131]( {more_info_url} ) izleyin: \n\n 1. [OAuth izin ekran\u0131na]( {oauth_consent_url} ) gidin ve yap\u0131land\u0131r\u0131n\n 1. [Kimlik Bilgileri]( {oauth_creds_url} ) \u00f6\u011fesine gidin ve **Kimlik Bilgileri Olu\u015ftur**'u t\u0131klay\u0131n.\n 1. A\u00e7\u0131l\u0131r listeden **OAuth istemci kimli\u011fi**'ni se\u00e7in.\n 1. Uygulama T\u00fcr\u00fc i\u00e7in **Web Uygulamas\u0131**'n\u0131 se\u00e7in.\n 1. *Yetkili y\u00f6nlendirme URI's\u0131* alt\u0131na ` {redirect_url} ` ekleyin." + }, "config": { "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", @@ -29,6 +33,32 @@ "description": "Google hesab\u0131n\u0131z\u0131 ba\u011flamak i\u00e7in [hesab\u0131n\u0131z\u0131 yetkilendirin]( {url} ). \n\n Yetkilendirmeden sonra, sa\u011flanan Auth Token kodunu a\u015fa\u011f\u0131ya kopyalay\u0131p yap\u0131\u015ft\u0131r\u0131n.", "title": "Google Hesab\u0131n\u0131 Ba\u011fla" }, + "auth_upgrade": { + "description": "App Auth, g\u00fcenli\u011fi art\u0131rmak i\u00e7in Google taraf\u0131ndan kullan\u0131mdan kald\u0131r\u0131ld\u0131 ve yeni uygulama kimlik bilgileri olu\u015fturarak i\u015flem yapman\u0131z gerekiyor. \n\n Takip etmek i\u00e7in [belgeleri]( {more_info_url} ) a\u00e7\u0131n, \u00e7\u00fcnk\u00fc sonraki ad\u0131mlar Nest cihazlar\u0131n\u0131za eri\u015fimi geri y\u00fcklemek i\u00e7in atman\u0131z gereken ad\u0131mlar konusunda size rehberlik edecektir.", + "title": "Nest: Uygulama Yetkilendirmesinin Kullan\u0131mdan Kald\u0131r\u0131lmas\u0131" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Bulut Proje Kimli\u011fi" + }, + "description": "Bulut Projesi Kimli\u011fini a\u015fa\u011f\u0131ya girin, \u00f6rne\u011fin *example-project-12345*. [Google Cloud Console]( {cloud_console_url} ) veya [daha fazla bilgi]( {more_info_url} ) i\u00e7in belgelere bak\u0131n.", + "title": "Nest: Bulut Proje Kimli\u011fini Girin" + }, + "create_cloud_project": { + "description": "Nest entegrasyonu, Ak\u0131ll\u0131 Cihaz Y\u00f6netimi API'sini kullanarak Nest Termostatlar\u0131n\u0131z\u0131, Kameralar\u0131n\u0131z\u0131 ve Kap\u0131 Zillerinizi entegre etmenize olanak tan\u0131r. SDM API **bir kereye mahsus 5 ABD dolar\u0131** tutar\u0131nda kurulum \u00fccreti gerektirir. [daha fazla bilgi]( {more_info_url} ) i\u00e7in belgelere bak\u0131n. \n\n 1. [Google Bulut Konsoluna]( {cloud_console_url} ) gidin.\n 1. Bu ilk projenizse, **Proje Olu\u015ftur**'a ve ard\u0131ndan **Yeni Proje**'ye t\u0131klay\u0131n.\n 1. Bulut Projenize bir Ad verin ve ard\u0131ndan **Olu\u015ftur**'a t\u0131klay\u0131n.\n 1. Bulut Projesi Kimli\u011fini kaydedin, \u00f6rne\u011fin *example-project-12345* daha sonra ihtiya\u00e7 duyaca\u011f\u0131n\u0131z i\u00e7in\n 1. [Smart Device Management API]( {sdm_api_url} ) için API Kitapl\u0131\u011f\u0131na gidin ve **Etkinle\u015ftir**'i t\u0131klay\u0131n.\n 1. [Cloud Pub/Sub API]( {pubsub_api_url} ) için API Kitapl\u0131\u011f\u0131'na gidin ve **Etkinle\u015ftir**'i t\u0131klay\u0131n. \n\n Bulut projeniz kuruldu\u011funda devam edin.", + "title": "Nest: Bulut Projesi olu\u015fturma ve yap\u0131land\u0131rma" + }, + "device_project": { + "data": { + "project_id": "Cihaz Eri\u015fim Projesi Kimli\u011fi" + }, + "description": "Kurmak i\u00e7n **5 ABD dolar\u0131 \u00fccret** gerektiren bir Nest Cihaz Eri\u015fimi projesi olu\u015fturun.\n 1. [Cihaz Eri\u015fim Konsolu]( {device_access_console_url} )'e gidin ve \u00f6deme ak\u0131\u015f\u0131ndan ge\u00e7in.\n 1. **Proje olu\u015ftur**'a t\u0131klay\u0131n\n 1. Cihaz Eri\u015fimi projenize bir ad verin ve **\u0130leri**'ye t\u0131klay\u0131n.\n 1. OAuth M\u00fc\u015fteri Kimli\u011finizi girin\n 1. **Etkinle\u015ftir** ve **Proje olu\u015ftur**'a t\u0131klayarak etkinlikleri etkinle\u015ftirin. \n\n Cihaz Eri\u015fim Projesi Kimli\u011finizi a\u015fa\u011f\u0131ya girin ([daha fazla bilgi]( {more_info_url} )).\n", + "title": "Yuva: Bir Cihaz Eri\u015fim Projesi Olu\u015fturun" + }, + "device_project_upgrade": { + "description": "Nest Device Access Project'i yeni OAuth \u0130stemci Kimli\u011finizle g\u00fcncelleyin ([daha fazla bilgi]( {more_info_url} ))\n 1. [Cihaz Eri\u015fim Konsolu]'na gidin ( {device_access_console_url} ).\n 1. *OAuth \u0130stemci Kimli\u011fi*'nin yan\u0131ndaki \u00e7\u00f6p kutusu simgesini t\u0131klay\u0131n.\n 1. `...` ta\u015fma men\u00fcs\u00fcn\u00fc; ve *M\u00fc\u015fteri Kimli\u011fi Ekle*'yi t\u0131klay\u0131n.\n 1. Yeni OAuth \u0130stemci Kimli\u011finizi girin ve **Ekle**'yi t\u0131klay\u0131n. \n\n OAuth M\u00fc\u015fteri Kimli\u011finiz: ` {client_id} `", + "title": "Nest: Cihaz Eri\u015fim Projesini G\u00fcncelle" + }, "init": { "data": { "flow_impl": "Sa\u011flay\u0131c\u0131" diff --git a/homeassistant/components/nest/translations/uk.json b/homeassistant/components/nest/translations/uk.json index 9ab8349670e..cfdb2c91ee2 100644 --- a/homeassistant/components/nest/translations/uk.json +++ b/homeassistant/components/nest/translations/uk.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u041e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e", "authorize_url_timeout": "\u041c\u0438\u043d\u0443\u0432 \u0447\u0430\u0441 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0456\u0457 \u043f\u043e\u0441\u0438\u043b\u0430\u043d\u043d\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457.", "missing_configuration": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0438 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f. \u0411\u0443\u0434\u044c \u043b\u0430\u0441\u043a\u0430, \u043e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 \u0456\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0456\u044f\u043c\u0438.", "no_url_available": "URL-\u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430. \u041e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0456\u0454\u044e] ({docs_url}) \u0434\u043b\u044f \u043e\u0442\u0440\u0438\u043c\u0430\u043d\u043d\u044f \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457 \u043f\u0440\u043e \u0446\u044e \u043f\u043e\u043c\u0438\u043b\u043a\u0443.", @@ -18,6 +19,25 @@ "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" }, "step": { + "auth_upgrade": { + "title": "Nest: \u0437\u0430\u0431\u043e\u0440\u043e\u043d\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457 \u0434\u043e\u0434\u0430\u0442\u043a\u0456\u0432" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID \u043f\u0440\u043e\u0435\u043a\u0442\u0443 Google Cloud" + } + }, + "create_cloud_project": { + "title": "Nest: \u0441\u0442\u0432\u043e\u0440\u0456\u0442\u044c \u0456 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0439\u0442\u0435 Cloud Project" + }, + "device_project": { + "data": { + "project_id": "ID \u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0443 \u0434\u043e \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e" + } + }, + "device_project_upgrade": { + "title": "Nest: \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044f \u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0443 \u0434\u043e \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e" + }, "init": { "data": { "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440" diff --git a/homeassistant/components/nest/translations/zh-Hant.json b/homeassistant/components/nest/translations/zh-Hant.json index c52a22e6970..11a4823d77a 100644 --- a/homeassistant/components/nest/translations/zh-Hant.json +++ b/homeassistant/components/nest/translations/zh-Hant.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "\u8ddf\u96a8 [\u8aaa\u660e]({more_info_url}) \u4ee5\u8a2d\u5b9a Cloud \u63a7\u5236\u53f0\uff1a\n\n1. \u700f\u89bd\u81f3 [OAuth \u63a7\u5236\u53f0\u756b\u9762]({oauth_consent_url}) \u4e26\u8a2d\u5b9a\n1. \u700f\u89bd\u81f3 [\u6191\u8b49]({oauth_creds_url}) \u4e26\u9ede\u9078 **\u5efa\u7acb\u6191\u8b49**\u3002\n1. \u7531\u4e0b\u62c9\u9078\u55ae\u4e2d\u9078\u64c7 **OAuth \u7528\u6236\u7aef ID**\u3002\n1. \u61c9\u7528\u7a0b\u5f0f\u985e\u578b\u5247\u9078\u64c7 **Web \u61c9\u7528\u7a0b\u5f0f**\u3002\n1. \u65bc *\u8a8d\u8b49\u91cd\u65b0\u5c0e\u5411 URI* \u4e2d\u65b0\u589e `{redirect_url}`\u3002" + }, "config": { "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002", "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", @@ -19,7 +23,7 @@ "subscriber_error": "\u672a\u77e5\u8a02\u95b1\u932f\u8aa4\uff0c\u8acb\u53c3\u95b1\u65e5\u8a8c", "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642", "unknown": "\u672a\u9810\u671f\u932f\u8aa4", - "wrong_project_id": "\u8acb\u8f38\u5165\u6709\u6548 Cloud \u5c08\u6848 ID\uff08\u53ef\u65bc Device Access Project ID \u4e2d\u627e\u5230\uff09" + "wrong_project_id": "\u8acb\u8f38\u5165\u6709\u6548 Cloud \u5c08\u6848 ID\uff08\u8207\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ID \u76f8\u540c\uff09" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "\u6b32\u9023\u7d50 Google \u5e33\u865f\u3001\u8acb\u5148 [\u8a8d\u8b49\u5e33\u865f]({url})\u3002\n\n\u65bc\u8a8d\u8b49\u5f8c\u3001\u65bc\u4e0b\u65b9\u8cbc\u4e0a\u8a8d\u8b49\u6b0a\u6756\u4ee3\u78bc\u3002", "title": "\u9023\u7d50 Google \u5e33\u865f" }, + "auth_upgrade": { + "description": "Google \u5df2\u4e0d\u518d\u63a8\u85a6\u4f7f\u7528 App Auth \u4ee5\u63d0\u9ad8\u5b89\u5168\u6027\u3001\u56e0\u6b64\u60a8\u9700\u8981\u5efa\u7acb\u65b0\u7684\u61c9\u7528\u7a0b\u5f0f\u6191\u8b49\u3002\n\n\u958b\u555f [\u76f8\u95dc\u6587\u4ef6]({more_info_url}) \u4e26\u8ddf\u96a8\u6b65\u9a5f\u6307\u5f15\u3001\u5c07\u5e36\u9818\u60a8\u5b58\u53d6\u6216\u56de\u5fa9\u60a8\u7684 Nest \u88dd\u7f6e\u3002", + "title": "Nest: App Auth \u5df2\u4e0d\u63a8\u85a6\u4f7f\u7528" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud \u5c08\u6848 ID" + }, + "description": "\u65bc\u4e0b\u65b9\u8f38\u5165 Cloud \u5c08\u6848 ID\u3002\u4f8b\u5982\uff1a*example-project-12345*\u3002\u8acb\u53c3\u95b1 [Google Cloud Console]({cloud_console_url}) \u6216\u76f8\u95dc\u6587\u4ef6\u4ee5\u7372\u5f97 [\u66f4\u8a73\u7d30\u8cc7\u8a0a]({more_info_url})\u3002", + "title": "Nest\uff1a\u8f38\u5165 Cloud \u5c08\u6848 ID" + }, + "create_cloud_project": { + "description": "Nest \u6574\u5408\u5c07\u5141\u8a31\u4f7f\u7528\u88dd\u7f6e\u7ba1\u7406 API \u4ee5\u6574\u5408 Nest \u6eab\u63a7\u5668\u3001\u651d\u5f71\u6a5f\u53ca\u9580\u9234\u3002SDM API **\u5c07\u5fc5\u9808\u652f\u4ed8 $5 \u7f8e\u91d1** \u4e00\u6b21\u6027\u7684\u8a2d\u5b9a\u8cbb\u7528\u3002\u8acb\u53c3\u95b1\u76f8\u95dc\u6587\u4ef6\u4ee5\u53d6\u5f97 [\u66f4\u591a\u8cc7\u8a0a]({more_info_url})\u3002\n\n1. \u700f\u89bd\u81f3 [Google Cloud \u63a7\u5236\u53f0]({cloud_console_url})\u3002\n1. \u5047\u5982\u9019\u662f\u7b2c\u4e00\u500b\u5c08\u6848\u3001\u9ede\u9078 **\u5efa\u7acb\u5c08\u6848** \u4e26\u9078\u64c7 **\u65b0\u5c08\u6848**\u3002\n1. \u5c0d Cloud \u5c08\u6848\u9032\u884c\u547d\u540d\u4e26\u9ede\u9078 **\u5efa\u7acb**\u3002\n1. \u5132\u5b58 Cloud \u5c08\u6848 ID\u3001\u4f8b\u5982\uff1a*example-project-12345*\u3001\u7a0d\u5f8c\u5c07\u6703\u7528\u4e0a\u3002\n1. \u700f\u89bd\u81f3 [\u667a\u6167\u88dd\u7f6e\u7ba1\u7406 API]({sdm_api_url}) API \u8cc7\u6599\u5eab\u4e26\u9ede\u9078 **\u555f\u7528**\u3002\n1. \u700f\u89bd\u81f3 [Cloud Pub/Sub API]({pubsub_api_url}) API \u8cc7\u6599\u5eab\u4e26\u9ede\u9078 **\u555f\u7528**\u3002\n\nCloud project \u8a2d\u5b9a\u5b8c\u6210\u5f8c\u7e7c\u7e8c\u3002", + "title": "Nest\uff1a\u5efa\u7acb\u4e26\u8a2d\u5b9a Cloud \u5c08\u6848" + }, + "device_project": { + "data": { + "project_id": "\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ID" + }, + "description": "\u5efa\u8b70 Nest \u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 **\u5c07\u6703\u9700\u8981\u652f\u4ed8 $5 \u7f8e\u91d1\u8cbb\u7528** \u4ee5\u9032\u884c\u8a2d\u5b9a\u3002\n1. \u9023\u7dda\u81f3 [\u88dd\u7f6e\u5b58\u53d6\u63a7\u5236\u53f0]({device_access_console_url})\u3001\u4e26\u9032\u884c\u4ed8\u6b3e\u7a0b\u5e8f\u3002\n1. \u9ede\u9078 **\u5efa\u7acb\u5c08\u6848**\n1. \u9032\u884c\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848\u547d\u540d\u3001\u4e26\u9ede\u9078 **\u4e0b\u4e00\u6b65**\u3002\n1. \u8f38\u5165 OAuth \u5ba2\u6236\u7aef ID\n1. \u9ede\u9078 **\u555f\u7528** \u4ee5\u555f\u7528\u4e8b\u4ef6\u4e26 **\u5efa\u7acb\u5c08\u6848**\u3002\n\n\u65bc\u4e0b\u65b9 ([\u66f4\u591a\u8cc7\u8a0a]({more_info_url})) \u8f38\u5165\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ID\u3002\n", + "title": "Nest\uff1a\u5efa\u7acb\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848" + }, + "device_project_upgrade": { + "description": "\u4f7f\u7528\u65b0\u5efa OAuth \u5ba2\u6236\u7aef ID \u66f4\u65b0 Nest \u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ([\u66f4\u8a73\u7d30\u8cc7\u8a0a]({more_info_url}))\n1. \u700f\u89bd\u81f3 [\u88dd\u7f6e\u5b58\u53d6\u63a7\u5236\u53f0]({device_access_console_url})\u3002\n1. \u9ede\u9078 *OAuth \u5ba2\u6236\u7aef ID* \u65c1\u7684\u5783\u573e\u6876\u5716\u6848\u3002\n1. \u9ede\u9078 `...` \u9078\u55ae\u4e26\u9078\u64c7 *\u65b0\u589e\u5ba2\u6236\u7aef ID*\u3002\n1. \u8f38\u5165\u65b0\u5efa OAuth \u5ba2\u6236\u7aef ID \u4e26\u9ede\u9078 **\u65b0\u589e**\u3002\n\nOAuth \u5ba2\u6236\u7aef ID \u70ba\uff1a`{client_id}`", + "title": "Nest\uff1a\u66f4\u65b0\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848" + }, "init": { "data": { "flow_impl": "\u8a8d\u8b49\u63d0\u4f9b\u8005" diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index bbd28e8398f..ba63c76ad66 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -1,7 +1,9 @@ """Config flow for Netatmo.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any import uuid import voluptuous as vol @@ -66,7 +68,7 @@ class NetatmoFlowHandler( return await super().async_step_user(user_input) - async def async_step_reauth(self, user_input: dict | None = None) -> FlowResult: + 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() diff --git a/homeassistant/components/netatmo/translations/bg.json b/homeassistant/components/netatmo/translations/bg.json index 95a038871be..30cbd4c7167 100644 --- a/homeassistant/components/netatmo/translations/bg.json +++ b/homeassistant/components/netatmo/translations/bg.json @@ -16,5 +16,14 @@ "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" } } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "\u0418\u043c\u0435 \u043d\u0430 \u043e\u0431\u043b\u0430\u0441\u0442\u0442\u0430" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/pt-BR.json b/homeassistant/components/netatmo/translations/pt-BR.json index 9f438868d43..b47c0ea3646 100644 --- a/homeassistant/components/netatmo/translations/pt-BR.json +++ b/homeassistant/components/netatmo/translations/pt-BR.json @@ -52,7 +52,7 @@ "lon_ne": "Longitude nordeste", "lon_sw": "Longitude sudoeste", "mode": "C\u00e1lculo", - "show_on_map": "[%key:component::iss::config::step::user::data::show_on_map%]" + "show_on_map": "Mostrar no mapa" }, "description": "Configure um sensor meteorol\u00f3gico p\u00fablico para uma \u00e1rea.", "title": "Sensor meteorol\u00f3gico p\u00fablico Netatmo" diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 679a93f8da1..a996699ab9e 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -15,8 +15,11 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( DOMAIN, KEY_COORDINATOR, + KEY_COORDINATOR_FIRMWARE, + KEY_COORDINATOR_LINK, KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, + KEY_COORDINATOR_UTIL, KEY_ROUTER, PLATFORMS, ) @@ -27,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) SPEED_TEST_INTERVAL = timedelta(seconds=1800) +SCAN_INTERVAL_FIRMWARE = timedelta(seconds=18000) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -83,6 +87,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from the router.""" return await router.async_get_speed_test() + async def async_check_firmware() -> dict[str, Any] | None: + """Check for new firmware of the router.""" + return await router.async_check_new_firmware() + + async def async_update_utilization() -> dict[str, Any] | None: + """Fetch data from the router.""" + return await router.async_get_utilization() + + async def async_check_link_status() -> dict[str, Any] | None: + """Fetch data from the router.""" + return await router.async_get_link_status() + # Create update coordinators coordinator = DataUpdateCoordinator( hass, @@ -105,16 +121,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=async_update_speed_test, update_interval=SPEED_TEST_INTERVAL, ) + coordinator_firmware = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{router.device_name} Firmware", + update_method=async_check_firmware, + update_interval=SCAN_INTERVAL_FIRMWARE, + ) + coordinator_utilization = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{router.device_name} Utilization", + update_method=async_update_utilization, + update_interval=SCAN_INTERVAL, + ) + coordinator_link = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{router.device_name} Ethernet Link Status", + update_method=async_check_link_status, + update_interval=SCAN_INTERVAL, + ) if router.track_devices: await coordinator.async_config_entry_first_refresh() await coordinator_traffic_meter.async_config_entry_first_refresh() + await coordinator_firmware.async_config_entry_first_refresh() + await coordinator_utilization.async_config_entry_first_refresh() + await coordinator_link.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = { KEY_ROUTER: router, KEY_COORDINATOR: coordinator, KEY_COORDINATOR_TRAFFIC: coordinator_traffic_meter, KEY_COORDINATOR_SPEED: coordinator_speed_test, + KEY_COORDINATOR_FIRMWARE: coordinator_firmware, + KEY_COORDINATOR_UTIL: coordinator_utilization, + KEY_COORDINATOR_LINK: coordinator_link, } hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 936777a7961..eaa32362baf 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -5,7 +5,13 @@ from homeassistant.const import Platform DOMAIN = "netgear" -PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] CONF_CONSIDER_HOME = "consider_home" @@ -13,6 +19,9 @@ KEY_ROUTER = "router" KEY_COORDINATOR = "coordinator" KEY_COORDINATOR_TRAFFIC = "coordinator_traffic" KEY_COORDINATOR_SPEED = "coordinator_speed" +KEY_COORDINATOR_FIRMWARE = "coordinator_firmware" +KEY_COORDINATOR_UTIL = "coordinator_utilization" +KEY_COORDINATOR_LINK = "coordinator_link" DEFAULT_CONSIDER_HOME = timedelta(seconds=180) DEFAULT_NAME = "Netgear router" diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index f65f5aa6686..5fd59faac83 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -2,7 +2,7 @@ "domain": "netgear", "name": "NETGEAR", "documentation": "https://www.home-assistant.io/integrations/netgear", - "requirements": ["pynetgear==0.10.4"], + "requirements": ["pynetgear==0.10.6"], "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], "iot_class": "local_polling", "config_flow": true, diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 67e573d0e92..a4f8a4df14e 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -228,6 +228,11 @@ class NetgearRouter: self._api.get_new_speed_test_result ) + async def async_get_link_status(self) -> dict[str, Any] | None: + """Check the ethernet link status of the router.""" + async with self._api_lock: + return await self.hass.async_add_executor_job(self._api.check_ethernet_link) + async def async_allow_block_device(self, mac: str, allow_block: str) -> None: """Allow or block a device connected to the router.""" async with self._api_lock: @@ -235,11 +240,26 @@ class NetgearRouter: self._api.allow_block_device, mac, allow_block ) + async def async_get_utilization(self) -> dict[str, Any] | None: + """Get the system information about utilization of the router.""" + async with self._api_lock: + return await self.hass.async_add_executor_job(self._api.get_system_info) + async def async_reboot(self) -> None: """Reboot the router.""" async with self._api_lock: await self.hass.async_add_executor_job(self._api.reboot) + async def async_check_new_firmware(self) -> None: + """Check for new firmware of the router.""" + async with self._api_lock: + return await self.hass.async_add_executor_job(self._api.check_new_firmware) + + async def async_update_new_firmware(self) -> None: + """Update the router to the latest firmware.""" + async with self._api_lock: + await self.hass.async_add_executor_job(self._api.update_new_firmware) + @property def port(self) -> int: """Port used by the API.""" diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index a1cf134beda..1ada340d1e1 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,8 +30,10 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( DOMAIN, KEY_COORDINATOR, + KEY_COORDINATOR_LINK, KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, + KEY_COORDINATOR_UTIL, KEY_ROUTER, ) from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterEntity @@ -244,6 +247,34 @@ SENSOR_SPEED_TYPES = [ ), ] +SENSOR_UTILIZATION = [ + NetgearSensorEntityDescription( + key="NewCPUUtilization", + name="CPU Utilization", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:cpu-64-bit", + state_class=SensorStateClass.MEASUREMENT, + ), + NetgearSensorEntityDescription( + key="NewMemoryUtilization", + name="Memory Utilization", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, + ), +] + +SENSOR_LINK_TYPES = [ + NetgearSensorEntityDescription( + key="NewEthernetLinkStatus", + name="Ethernet Link Status", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:ethernet", + ), +] + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -253,6 +284,8 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] coordinator_traffic = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_TRAFFIC] coordinator_speed = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_SPEED] + coordinator_utilization = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_UTIL] + coordinator_link = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_LINK] # Router entities router_entities = [] @@ -267,6 +300,16 @@ async def async_setup_entry( NetgearRouterSensorEntity(coordinator_speed, router, description) ) + for description in SENSOR_UTILIZATION: + router_entities.append( + NetgearRouterSensorEntity(coordinator_utilization, router, description) + ) + + for description in SENSOR_LINK_TYPES: + router_entities.append( + NetgearRouterSensorEntity(coordinator_link, router, description) + ) + async_add_entities(router_entities) # Entities per network device diff --git a/homeassistant/components/netgear/translations/sv.json b/homeassistant/components/netgear/translations/sv.json new file mode 100644 index 00000000000..2672bc03eef --- /dev/null +++ b/homeassistant/components/netgear/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn (frivilligt)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py new file mode 100644 index 00000000000..8d4a9b4912a --- /dev/null +++ b/homeassistant/components/netgear/update.py @@ -0,0 +1,81 @@ +"""Update entities for Netgear devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +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, NetgearRouterEntity + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up update entities for Netgear component.""" + router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] + coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_FIRMWARE] + entities = [NetgearUpdateEntity(coordinator, router)] + + async_add_entities(entities) + + +class NetgearUpdateEntity(NetgearRouterEntity, UpdateEntity): + """Update entity for a Netgear device.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + + def __init__( + self, + coordinator: DataUpdateCoordinator, + router: NetgearRouter, + ) -> None: + """Initialize a Netgear device.""" + super().__init__(coordinator, router) + self._name = f"{router.device_name} Update" + self._unique_id = f"{router.serial_number}-update" + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + if self.coordinator.data is not None: + return self.coordinator.data.get("CurrentVersion") + return None + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if self.coordinator.data is not None: + new_version = self.coordinator.data.get("NewVersion") + if new_version is not None: + return new_version + return self.installed_version + + @property + def release_summary(self) -> str | None: + """Release summary.""" + if self.coordinator.data is not None: + return self.coordinator.data.get("ReleaseNote") + return None + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + await self._router.async_update_new_firmware() + + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index ab491c0a271..355c17a2ed1 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -5,11 +5,13 @@ import logging import aiohttp from nexia.const import BRAND_NEXIA from nexia.home import NexiaHome +from nexia.thermostat import NexiaThermostat from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -66,8 +68,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) - if unload_ok: + 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_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a nexia config entry from a device.""" + coordinator: NexiaDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + nexia_home: NexiaHome = coordinator.nexia_home + dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN} + for thermostat_id in nexia_home.get_thermostat_ids(): + if thermostat_id in dev_ids: + return False + thermostat: NexiaThermostat = nexia_home.get_thermostat_by_id(thermostat_id) + for zone_id in thermostat.get_zone_ids(): + if zone_id in dev_ids: + return False + return True diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 4bae2d9a15d..cc5e6de8641 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia/American Standard/Trane", - "requirements": ["nexia==1.0.2"], + "requirements": ["nexia==2.0.1"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/homeassistant/components/nexia/translations/bg.json b/homeassistant/components/nexia/translations/bg.json index 78264e2adbd..7aa8fb275ea 100644 --- a/homeassistant/components/nexia/translations/bg.json +++ b/homeassistant/components/nexia/translations/bg.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 458015c5bb6..38622fc0060 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,58 +1,46 @@ """The NFAndroidTV integration.""" from notifications_android_tv.notifications import ConnectError, Notifications -from homeassistant.components.notify import DOMAIN as NOTIFY -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM, Platform +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 import discovery +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NFAndroidTV component.""" - hass.data.setdefault(DOMAIN, {}) - # Iterate all entries for notify to only get nfandroidtv - if NOTIFY in config: - for entry in config[NOTIFY]: - if entry[CONF_PLATFORM] == DOMAIN: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) + hass.data[DATA_HASS_CONFIG] = config return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NFAndroidTV from a config entry.""" - host = entry.data[CONF_HOST] - name = entry.data[CONF_NAME] - try: - await hass.async_add_executor_job(Notifications, host) + await hass.async_add_executor_job(Notifications, entry.data[CONF_HOST]) except ConnectError as ex: - raise ConfigEntryNotReady("Failed to connect") from ex + raise ConfigEntryNotReady( + f"Failed to connect to host: {entry.data[CONF_HOST]}" + ) from ex hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_HOST: host, - CONF_NAME: name, - } hass.async_create_task( discovery.async_load_platform( hass, Platform.NOTIFY, DOMAIN, - hass.data[DOMAIN][entry.entry_id], - hass.data[DOMAIN], + dict(entry.data), + hass.data[DATA_HASS_CONFIG], ) ) @@ -61,9 +49,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index defa4467f3a..88eebe1b4d4 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -26,46 +26,28 @@ class NFAndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - host = user_input[CONF_HOST] - name = user_input[CONF_NAME] - await self.async_set_unique_id(host) - self._abort_if_unique_id_configured() - error = await self._async_try_connect(host) - if error is None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_NAME: user_input[CONF_NAME]} + ) + if not (error := await self._async_try_connect(user_input[CONF_HOST])): return self.async_create_entry( - title=name, - data={CONF_HOST: host, CONF_NAME: name}, + title=user_input[CONF_NAME], + data=user_input, ) errors["base"] = error - user_input = user_input or {} return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, } ), errors=errors, ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == import_config[CONF_HOST]: - _LOGGER.warning( - "Already configured. This yaml configuration has already been imported. Please remove it" - ) - return self.async_abort(reason="already_configured") - if CONF_NAME not in import_config: - import_config[CONF_NAME] = f"{DEFAULT_NAME} {import_config[CONF_HOST]}" - - return await self.async_step_user(import_config) - async def _async_try_connect(self, host: str) -> str | None: """Try connecting to Android TV / Fire TV.""" try: diff --git a/homeassistant/components/nfandroidtv/const.py b/homeassistant/components/nfandroidtv/const.py index 12449a9b046..4d4a7c82ecb 100644 --- a/homeassistant/components/nfandroidtv/const.py +++ b/homeassistant/components/nfandroidtv/const.py @@ -7,6 +7,8 @@ CONF_TRANSPARENCY = "transparency" CONF_COLOR = "color" CONF_INTERRUPT = "interrupt" +DATA_HASS_CONFIG = "nfandroid_hass_config" + DEFAULT_NAME = "Android TV / Fire TV" DEFAULT_TIMEOUT = 5 diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index b5e4962e9be..c70272d3835 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -14,10 +14,9 @@ from homeassistant.components.notify import ( ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_HOST, CONF_TIMEOUT +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -43,53 +42,21 @@ from .const import ( ATTR_INTERRUPT, ATTR_POSITION, ATTR_TRANSPARENCY, - CONF_COLOR, - CONF_DURATION, - CONF_FONTSIZE, - CONF_INTERRUPT, - CONF_POSITION, - CONF_TRANSPARENCY, DEFAULT_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) -# Deprecated in Home Assistant 2021.8 -PLATFORM_SCHEMA = cv.deprecated( - vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DURATION): vol.Coerce(int), - vol.Optional(CONF_FONTSIZE): vol.In(Notifications.FONTSIZES.keys()), - vol.Optional(CONF_POSITION): vol.In(Notifications.POSITIONS.keys()), - vol.Optional(CONF_TRANSPARENCY): vol.In( - Notifications.TRANSPARENCIES.keys() - ), - vol.Optional(CONF_COLOR): vol.In(Notifications.BKG_COLORS.keys()), - vol.Optional(CONF_TIMEOUT): vol.Coerce(int), - vol.Optional(CONF_INTERRUPT): cv.boolean, - } - ), - ) -) - async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, -) -> NFAndroidTVNotificationService: +) -> NFAndroidTVNotificationService | None: """Get the NFAndroidTV notification service.""" - if discovery_info is not None: - notify = await hass.async_add_executor_job( - Notifications, discovery_info[CONF_HOST] - ) - return NFAndroidTVNotificationService( - notify, - hass.config.is_allowed_path, - ) - notify = await hass.async_add_executor_job(Notifications, config.get(CONF_HOST)) + if discovery_info is None: + return None + notify = await hass.async_add_executor_job(Notifications, discovery_info[CONF_HOST]) return NFAndroidTVNotificationService( notify, hass.config.is_allowed_path, @@ -128,21 +95,21 @@ class NFAndroidTVNotificationService(BaseNotificationService): ) except ValueError: _LOGGER.warning( - "Invalid duration-value: %s", str(data.get(ATTR_DURATION)) + "Invalid duration-value: %s", data.get(ATTR_DURATION) ) if ATTR_FONTSIZE in data: if data.get(ATTR_FONTSIZE) in Notifications.FONTSIZES: fontsize = data.get(ATTR_FONTSIZE) else: _LOGGER.warning( - "Invalid fontsize-value: %s", str(data.get(ATTR_FONTSIZE)) + "Invalid fontsize-value: %s", data.get(ATTR_FONTSIZE) ) if ATTR_POSITION in data: if data.get(ATTR_POSITION) in Notifications.POSITIONS: position = data.get(ATTR_POSITION) else: _LOGGER.warning( - "Invalid position-value: %s", str(data.get(ATTR_POSITION)) + "Invalid position-value: %s", data.get(ATTR_POSITION) ) if ATTR_TRANSPARENCY in data: if data.get(ATTR_TRANSPARENCY) in Notifications.TRANSPARENCIES: @@ -150,24 +117,21 @@ class NFAndroidTVNotificationService(BaseNotificationService): else: _LOGGER.warning( "Invalid transparency-value: %s", - str(data.get(ATTR_TRANSPARENCY)), + data.get(ATTR_TRANSPARENCY), ) if ATTR_COLOR in data: if data.get(ATTR_COLOR) in Notifications.BKG_COLORS: bkgcolor = data.get(ATTR_COLOR) else: - _LOGGER.warning( - "Invalid color-value: %s", str(data.get(ATTR_COLOR)) - ) + _LOGGER.warning("Invalid color-value: %s", data.get(ATTR_COLOR)) if ATTR_INTERRUPT in data: try: interrupt = cv.boolean(data.get(ATTR_INTERRUPT)) except vol.Invalid: _LOGGER.warning( - "Invalid interrupt-value: %s", str(data.get(ATTR_INTERRUPT)) + "Invalid interrupt-value: %s", data.get(ATTR_INTERRUPT) ) - imagedata = data.get(ATTR_IMAGE) if data else None - if imagedata is not None: + if imagedata := data.get(ATTR_IMAGE): image_file = self.load_file( url=imagedata.get(ATTR_IMAGE_URL), local_path=imagedata.get(ATTR_IMAGE_PATH), @@ -175,8 +139,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): password=imagedata.get(ATTR_IMAGE_PASSWORD), auth=imagedata.get(ATTR_IMAGE_AUTH), ) - icondata = data.get(ATTR_ICON) if data else None - if icondata is not None: + if icondata := data.get(ATTR_ICON): icon = self.load_file( url=icondata.get(ATTR_ICON_URL), local_path=icondata.get(ATTR_ICON_PATH), diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 16b6d01b8c2..c5375c96785 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -42,6 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -49,6 +51,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + class NINADataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching NINA data API.""" diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 0574de96681..aa06b00e0ad 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -7,9 +7,14 @@ from pynina import ApiError, Nina import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get, +) from .const import ( _LOGGER, @@ -22,6 +27,58 @@ from .const import ( ) +def swap_key_value(dict_to_sort: dict[str, str]) -> dict[str, str]: + """Swap keys and values in dict.""" + all_region_codes_swaped: dict[str, str] = {} + + for key, value in dict_to_sort.items(): + if value not in all_region_codes_swaped: + all_region_codes_swaped[value] = key + else: + for i in range(len(dict_to_sort)): + tmp_value: str = f"{value}_{i}" + if tmp_value not in all_region_codes_swaped: + all_region_codes_swaped[tmp_value] = key + break + + return dict(sorted(all_region_codes_swaped.items(), key=lambda ele: ele[1])) + + +def split_regions( + _all_region_codes_sorted: dict[str, str], regions: dict[str, dict[str, Any]] +) -> dict[str, dict[str, Any]]: + """Split regions alphabetical.""" + for index, name in _all_region_codes_sorted.items(): + for region_name, grouping_letters in CONST_REGION_MAPPING.items(): + if name[0] in grouping_letters: + regions[region_name][index] = name + break + return regions + + +def prepare_user_input( + user_input: dict[str, Any], _all_region_codes_sorted: dict[str, str] +) -> dict[str, Any]: + """Prepare the user inputs.""" + tmp: dict[str, Any] = {} + + for reg in user_input[CONF_REGIONS]: + tmp[_all_region_codes_sorted[reg]] = reg.split("_", 1)[0] + + compact: dict[str, Any] = {} + + for key, val in tmp.items(): + if val in compact: + # Abenberg, St + Abenberger Wald + compact[val] = f"{compact[val]} + {key}" + break + compact[val] = key + + user_input[CONF_REGIONS] = compact + + return user_input + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for NINA.""" @@ -50,7 +107,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): nina: Nina = Nina(async_get_clientsession(self.hass)) try: - self._all_region_codes_sorted = self.swap_key_value( + self._all_region_codes_sorted = swap_key_value( await nina.getAllRegionalCodes() ) except ApiError: @@ -59,7 +116,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception: %s", err) return self.async_abort(reason="unknown") - self.split_regions() + self.regions = split_regions(self._all_region_codes_sorted, self.regions) if user_input is not None and not errors: user_input[CONF_REGIONS] = [] @@ -69,23 +126,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_REGIONS] += group_input if user_input[CONF_REGIONS]: - tmp: dict[str, Any] = {} - for reg in user_input[CONF_REGIONS]: - tmp[self._all_region_codes_sorted[reg]] = reg.split("_", 1)[0] - - compact: dict[str, Any] = {} - - for key, val in tmp.items(): - if val in compact: - # Abenberg, St + Abenberger Wald - compact[val] = f"{compact[val]} + {key}" - break - compact[val] = key - - user_input[CONF_REGIONS] = compact - - return self.async_create_entry(title="NINA", data=user_input) + return self.async_create_entry( + title="NINA", + data=prepare_user_input(user_input, self._all_region_codes_sorted), + ) errors["base"] = "no_selection" @@ -107,26 +152,114 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) @staticmethod - def swap_key_value(dict_to_sort: dict[str, str]) -> dict[str, str]: - """Swap keys and values in dict.""" - all_region_codes_swaped: dict[str, str] = {} + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) - for key, value in dict_to_sort.items(): - if value not in all_region_codes_swaped: - all_region_codes_swaped[value] = key - else: - for i in range(len(dict_to_sort)): - tmp_value: str = f"{value}_{i}" - if tmp_value not in all_region_codes_swaped: - all_region_codes_swaped[tmp_value] = key - break - return dict(sorted(all_region_codes_swaped.items(), key=lambda ele: ele[1])) +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for nut.""" - def split_regions(self) -> None: - """Split regions alphabetical.""" - for index, name in self._all_region_codes_sorted.items(): - for region_name, grouping_letters in CONST_REGION_MAPPING.items(): - if name[0] in grouping_letters: - self.regions[region_name][index] = name - break + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + self.data = dict(self.config_entry.data) + + self._all_region_codes_sorted: dict[str, str] = {} + self.regions: dict[str, dict[str, Any]] = {} + + for name in CONST_REGIONS: + self.regions[name] = {} + if name not in self.data: + self.data[name] = [] + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + errors: dict[str, Any] = {} + + if not self._all_region_codes_sorted: + nina: Nina = Nina(async_get_clientsession(self.hass)) + + try: + self._all_region_codes_sorted = swap_key_value( + await nina.getAllRegionalCodes() + ) + except ApiError: + errors["base"] = "cannot_connect" + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", err) + return self.async_abort(reason="unknown") + + self.regions = split_regions(self._all_region_codes_sorted, self.regions) + + if user_input is not None and not errors: + user_input[CONF_REGIONS] = [] + + for group in CONST_REGIONS: + if group_input := user_input.get(group): + user_input[CONF_REGIONS] += group_input + + if user_input[CONF_REGIONS]: + + user_input = prepare_user_input( + user_input, self._all_region_codes_sorted + ) + + entity_registry = async_get(self.hass) + + entries = async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) + + removed_entities_slots = [ + f"{region}-{slot_id}" + for region in self.data[CONF_REGIONS] + for slot_id in range(0, self.data[CONF_MESSAGE_SLOTS] + 1) + if slot_id > user_input[CONF_MESSAGE_SLOTS] + ] + + removed_entites_area = [ + f"{cfg_region}-{slot_id}" + for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1) + for cfg_region in self.data[CONF_REGIONS] + if cfg_region not in user_input[CONF_REGIONS] + ] + + for entry in entries: + for entity_uid in list( + set(removed_entities_slots + removed_entites_area) + ): + if entry.unique_id == entity_uid: + entity_registry.async_remove(entry.entity_id) + + self.hass.config_entries.async_update_entry( + self.config_entry, data=user_input + ) + + return self.async_create_entry(title="", data=None) + + errors["base"] = "no_selection" + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + **{ + vol.Optional( + region, default=self.data[region] + ): cv.multi_select(self.regions[region]) + for region in CONST_REGIONS + }, + vol.Required( + CONF_MESSAGE_SLOTS, + default=self.data[CONF_MESSAGE_SLOTS], + ): vol.All(int, vol.Range(min=1, max=20)), + vol.Required( + CONF_FILTER_CORONA, + default=self.data[CONF_FILTER_CORONA], + ): cv.boolean, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 49ecf7fa7fa..b22c2640084 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -23,5 +23,27 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "options": { + "step": { + "init": { + "title": "Options", + "data": { + "_a_to_d": "City/county (A-D)", + "_e_to_h": "City/county (E-H)", + "_i_to_l": "City/county (I-L)", + "_m_to_q": "City/county (M-Q)", + "_r_to_u": "City/county (R-U)", + "_v_to_z": "City/county (V-Z)", + "slots": "Maximum warnings per city/county", + "corona_filter": "Remove Corona Warnings" + } + } + }, + "error": { + "no_selection": "Please select at least one city/county", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } } } diff --git a/homeassistant/components/nina/translations/en.json b/homeassistant/components/nina/translations/en.json index 793cbf595f1..0e46b30512d 100644 --- a/homeassistant/components/nina/translations/en.json +++ b/homeassistant/components/nina/translations/en.json @@ -23,5 +23,27 @@ "title": "Select city/county" } } + }, + "options": { + "error": { + "cannot_connect": "Failed to connect", + "no_selection": "Please select at least one city/county", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "data": { + "_a_to_d": "City/county (A-D)", + "_e_to_h": "City/county (E-H)", + "_i_to_l": "City/county (I-L)", + "_m_to_q": "City/county (M-Q)", + "_r_to_u": "City/county (R-U)", + "_v_to_z": "City/county (V-Z)", + "corona_filter": "Remove Corona Warnings", + "slots": "Maximum warnings per city/county" + }, + "title": "Options" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 00624748aba..fdb03652756 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -215,10 +215,9 @@ class NMBSSensor(SensorEntity): delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) departure = get_time_until(self._attrs["departure"]["time"]) + canceled = int(self._attrs["departure"]["canceled"]) attrs = { - "departure": f"In {departure} minutes", - "departure_minutes": departure, "destination": self._station_to, "direction": self._attrs["departure"]["direction"]["name"], "platform_arriving": self._attrs["arrival"]["platform"], @@ -227,6 +226,15 @@ class NMBSSensor(SensorEntity): ATTR_ATTRIBUTION: "https://api.irail.be/", } + if canceled != 1: + attrs["departure"] = f"In {departure} minutes" + attrs["departure_minutes"] = departure + attrs["canceled"] = False + else: + attrs["departure"] = None + attrs["departure_minutes"] = None + attrs["canceled"] = True + if self._show_on_map and self.station_coordinates: attrs[ATTR_LATITUDE] = self.station_coordinates[0] attrs[ATTR_LONGITUDE] = self.station_coordinates[1] diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index c9e59107c1b..917c0f8ebb9 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Notion integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from aionotion import async_get_client @@ -73,9 +74,9 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._username, data=data) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/nuheat/translations/bg.json b/homeassistant/components/nuheat/translations/bg.json new file mode 100644 index 00000000000..03ace4428b1 --- /dev/null +++ b/homeassistant/components/nuheat/translations/bg.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 6976c2dc682..a59b0a62f70 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,16 +1,19 @@ """The nuki component.""" +from collections import defaultdict from datetime import timedelta import logging import async_timeout -from pynuki import NukiBridge +from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException +from pynuki.device import NukiDevice from requests.exceptions import RequestException from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -34,21 +37,40 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK] UPDATE_INTERVAL = timedelta(seconds=30) -def _get_bridge_devices(bridge): +def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOpener]]: return bridge.locks, bridge.openers -def _update_devices(devices): +def _update_devices(devices: list[NukiDevice]) -> dict[str, set[str]]: + """ + Update the Nuki devices. + + Returns: + A dict with the events to be fired. The event type is the key and the device ids are the value + """ + + events: dict[str, set[str]] = defaultdict(set) + for device in devices: for level in (False, True): try: - device.update(level) + if isinstance(device, NukiOpener): + last_ring_action_state = device.ring_action_state + + device.update(level) + + if not last_ring_action_state and device.ring_action_state: + events["ring"].add(device.nuki_id) + else: + device.update(level) except RequestException: continue if device.state not in ERROR_STATES: break + return events + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nuki entry.""" @@ -85,12 +107,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with async_timeout.timeout(10): - await hass.async_add_executor_job(_update_devices, locks + openers) + events = await hass.async_add_executor_job( + _update_devices, locks + openers + ) except InvalidCredentialsException as err: raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err except RequestException as err: raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + ent_reg = er.async_get(hass) + for event, device_ids in events.items(): + for device_id in device_ids: + entity_id = ent_reg.async_get_entity_id( + Platform.LOCK, DOMAIN, device_id + ) + event_data = { + "entity_id": entity_id, + "type": event, + } + hass.bus.async_fire("nuki_event", event_data) + coordinator = DataUpdateCoordinator( hass, _LOGGER, @@ -136,7 +172,9 @@ class NukiEntity(CoordinatorEntity): """ - def __init__(self, coordinator, nuki_device): + def __init__( + self, coordinator: DataUpdateCoordinator[None], nuki_device: NukiDevice + ) -> None: """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self._nuki_device = nuki_device diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 054d0aaa219..85144d9bb77 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -1,5 +1,7 @@ """Config flow to configure the Nuki integration.""" +from collections.abc import Mapping import logging +from typing import Any from pynuki import NukiBridge from pynuki.bridge import InvalidCredentialsException @@ -80,9 +82,9 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_validate() - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._data = data + self._data = entry_data return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 6a39bea8cb6..8b6c843f48a 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -1,6 +1,10 @@ """Nuki.io lock platform.""" -from abc import ABC, abstractmethod +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Any + +from pynuki import NukiLock, NukiOpener from pynuki.constants import MODE_OPENER_CONTINUOUS import voluptuous as vol @@ -63,28 +67,22 @@ class NukiDeviceEntity(NukiEntity, LockEntity, ABC): _attr_supported_features = LockEntityFeature.OPEN @property - def name(self): + def name(self) -> str | None: """Return the name of the lock.""" return self._nuki_device.name @property - def unique_id(self) -> str: + def unique_id(self) -> str | None: """Return a unique ID.""" return self._nuki_device.nuki_id @property - @abstractmethod - def is_locked(self): - """Return true if lock is locked.""" - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" - data = { + return { ATTR_BATTERY_CRITICAL: self._nuki_device.battery_critical, ATTR_NUKI_ID: self._nuki_device.nuki_id, } - return data @property def available(self) -> bool: @@ -92,39 +90,41 @@ class NukiDeviceEntity(NukiEntity, LockEntity, ABC): return super().available and self._nuki_device.state not in ERROR_STATES @abstractmethod - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the device.""" @abstractmethod - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" @abstractmethod - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Open the door latch.""" class NukiLockEntity(NukiDeviceEntity): """Representation of a Nuki lock.""" + _nuki_device: NukiLock + @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._nuki_device.is_locked - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the device.""" self._nuki_device.lock() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self._nuki_device.unlock() - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Open the door latch.""" self._nuki_device.unlatch() - def lock_n_go(self, unlatch): + def lock_n_go(self, unlatch: bool) -> None: """Lock and go. This will first unlock the door, then wait for 20 seconds (or another @@ -136,30 +136,32 @@ class NukiLockEntity(NukiDeviceEntity): class NukiOpenerEntity(NukiDeviceEntity): """Representation of a Nuki opener.""" + _nuki_device: NukiOpener + @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if either ring-to-open or continuous mode is enabled.""" return not ( self._nuki_device.is_rto_activated or self._nuki_device.mode == MODE_OPENER_CONTINUOUS ) - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Disable ring-to-open.""" self._nuki_device.deactivate_rto() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Enable ring-to-open.""" self._nuki_device.activate_rto() - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Buzz open the door.""" self._nuki_device.electric_strike_actuation() - def lock_n_go(self, unlatch): + def lock_n_go(self, unlatch: bool) -> None: """Stub service.""" - def set_continuous_mode(self, enable): + def set_continuous_mode(self, enable: bool) -> None: """Continuous Mode. This feature will cause the door to automatically open when anyone diff --git a/homeassistant/components/nuki/translations/sv.json b/homeassistant/components/nuki/translations/sv.json new file mode 100644 index 00000000000..563e2d4a773 --- /dev/null +++ b/homeassistant/components/nuki/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 47a80f00561..fe438ea6aea 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -1,16 +1,20 @@ """Component to allow numeric input for platforms.""" from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Callable +from contextlib import suppress +import dataclasses from datetime import timedelta +import inspect import logging -from typing import Any, final +from math import ceil, floor +from typing import Any, Final, final import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE +from homeassistant.const import ATTR_MODE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -18,7 +22,9 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.util import temperature as temperature_util from .const import ( ATTR_MAX, @@ -41,6 +47,16 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) +class NumberDeviceClass(StrEnum): + """Device class for numbers.""" + + # temperature (C/F) + TEMPERATURE = "temperature" + + +DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) + + class NumberMode(StrEnum): """Modes for number entities.""" @@ -49,6 +65,11 @@ class NumberMode(StrEnum): SLIDER = "slider" +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + NumberDeviceClass.TEMPERATURE: temperature_util.convert, +} + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Number entities.""" component = hass.data[DOMAIN] = EntityComponent( @@ -72,7 +93,15 @@ async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> No raise ValueError( f"Value {value} for {entity.name} is outside valid range {entity.min_value} - {entity.max_value}" ) - await entity.async_set_value(value) + try: + native_value = entity.convert_to_native_value(value) + # Clamp to the native range + native_value = min( + max(native_value, entity.native_min_value), entity.native_max_value + ) + await entity.async_set_native_value(native_value) + except NotImplementedError: + await entity.async_set_value(value) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -87,25 +116,115 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass +@dataclasses.dataclass class NumberEntityDescription(EntityDescription): """A class that describes number entities.""" - max_value: float | None = None - min_value: float | None = None - step: float | None = None + max_value: None = None + min_value: None = None + native_max_value: float | None = None + native_min_value: float | None = None + native_unit_of_measurement: str | None = None + native_step: float | None = None + step: None = None + unit_of_measurement: None = None # Type override, use native_unit_of_measurement + + def __post_init__(self) -> None: + """Post initialisation processing.""" + if ( + self.max_value is not None + or self.min_value is not None + or self.step is not None + or self.unit_of_measurement is not None + ): + if self.__class__.__name__ == "NumberEntityDescription": # type: ignore[unreachable] + caller = inspect.stack()[2] + module = inspect.getmodule(caller[0]) + else: + module = inspect.getmodule(self) + if module and module.__file__ and "custom_components" in module.__file__: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s is setting deprecated attributes on an instance of " + "NumberEntityDescription, this is not valid and will be unsupported " + "from Home Assistant 2022.10. Please %s", + module.__name__ if module else self.__class__.__name__, + report_issue, + ) + self.native_unit_of_measurement = self.unit_of_measurement + + +def ceil_decimal(value: float, precision: float = 0) -> float: + """Return the ceiling of f with d decimals. + + This is a simple implementation which ignores floating point inexactness. + """ + factor = 10**precision + return ceil(value * factor) / factor + + +def floor_decimal(value: float, precision: float = 0) -> float: + """Return the floor of f with d decimals. + + This is a simple implementation which ignores floating point inexactness. + """ + factor = 10**precision + return floor(value * factor) / factor class NumberEntity(Entity): """Representation of a Number entity.""" entity_description: NumberEntityDescription - _attr_max_value: float - _attr_min_value: float + _attr_max_value: None + _attr_min_value: None _attr_state: None = None - _attr_step: float + _attr_step: None _attr_mode: NumberMode = NumberMode.AUTO - _attr_value: float + _attr_value: None + _attr_native_max_value: float + _attr_native_min_value: float + _attr_native_step: float + _attr_native_value: float + _attr_native_unit_of_measurement: str | None + _deprecated_number_entity_reported = False + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + if any( + method in cls.__dict__ + for method in ( + "async_set_value", + "max_value", + "min_value", + "set_value", + "step", + "unit_of_measurement", + "value", + ) + ): + module = inspect.getmodule(cls) + if module and module.__file__ and "custom_components" in module.__file__: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s::%s is overriding deprecated methods on an instance of " + "NumberEntity, this is not valid and will be unsupported " + "from Home Assistant 2022.10. Please %s", + cls.__module__, + cls.__name__, + report_issue, + ) @property def capability_attributes(self) -> dict[str, Any]: @@ -118,39 +237,86 @@ class NumberEntity(Entity): } @property + def native_min_value(self) -> float: + """Return the minimum value.""" + if hasattr(self, "_attr_native_min_value"): + return self._attr_native_min_value + if ( + hasattr(self, "entity_description") + and self.entity_description.native_min_value is not None + ): + return self.entity_description.native_min_value + return DEFAULT_MIN_VALUE + + @property + @final def min_value(self) -> float: """Return the minimum value.""" if hasattr(self, "_attr_min_value"): - return self._attr_min_value + self._report_deprecated_number_entity() + return self._attr_min_value # type: ignore[return-value] if ( hasattr(self, "entity_description") and self.entity_description.min_value is not None ): + self._report_deprecated_number_entity() # type: ignore[unreachable] return self.entity_description.min_value - return DEFAULT_MIN_VALUE + return self._convert_to_state_value(self.native_min_value, floor_decimal) @property + def native_max_value(self) -> float: + """Return the maximum value.""" + if hasattr(self, "_attr_native_max_value"): + return self._attr_native_max_value + if ( + hasattr(self, "entity_description") + and self.entity_description.native_max_value is not None + ): + return self.entity_description.native_max_value + return DEFAULT_MAX_VALUE + + @property + @final def max_value(self) -> float: """Return the maximum value.""" if hasattr(self, "_attr_max_value"): - return self._attr_max_value + self._report_deprecated_number_entity() + return self._attr_max_value # type: ignore[return-value] if ( hasattr(self, "entity_description") and self.entity_description.max_value is not None ): + self._report_deprecated_number_entity() # type: ignore[unreachable] return self.entity_description.max_value - return DEFAULT_MAX_VALUE + return self._convert_to_state_value(self.native_max_value, ceil_decimal) @property + def native_step(self) -> float | None: + """Return the increment/decrement step.""" + if ( + hasattr(self, "entity_description") + and self.entity_description.native_step is not None + ): + return self.entity_description.native_step + return None + + @property + @final def step(self) -> float: """Return the increment/decrement step.""" if hasattr(self, "_attr_step"): - return self._attr_step + self._report_deprecated_number_entity() + return self._attr_step # type: ignore[return-value] if ( hasattr(self, "entity_description") and self.entity_description.step is not None ): + self._report_deprecated_number_entity() # type: ignore[unreachable] return self.entity_description.step + if hasattr(self, "_attr_native_step"): + return self._attr_native_step + if (native_step := self.native_step) is not None: + return native_step step = DEFAULT_STEP value_range = abs(self.max_value - self.min_value) if value_range != 0: @@ -170,14 +336,183 @@ class NumberEntity(Entity): return self.value @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, if any.""" + if hasattr(self, "_attr_native_unit_of_measurement"): + return self._attr_native_unit_of_measurement + if hasattr(self, "entity_description"): + return self.entity_description.native_unit_of_measurement + return None + + @property + @final + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, after unit conversion.""" + if hasattr(self, "_attr_unit_of_measurement"): + return self._attr_unit_of_measurement + if ( + hasattr(self, "entity_description") + and self.entity_description.unit_of_measurement is not None + ): + return self.entity_description.unit_of_measurement # type: ignore[unreachable] + + native_unit_of_measurement = self.native_unit_of_measurement + + if ( + self.device_class == NumberDeviceClass.TEMPERATURE + and native_unit_of_measurement in (TEMP_CELSIUS, TEMP_FAHRENHEIT) + ): + return self.hass.config.units.temperature_unit + + return native_unit_of_measurement + + @property + def native_value(self) -> float | None: + """Return the value reported by the number.""" + return self._attr_native_value + + @property + @final def value(self) -> float | None: """Return the entity value to represent the entity state.""" - return self._attr_value + if hasattr(self, "_attr_value"): + self._report_deprecated_number_entity() + return self._attr_value + if (native_value := self.native_value) is None: + return native_value + return self._convert_to_state_value(native_value, round) + + def set_native_value(self, value: float) -> None: + """Set new value.""" + raise NotImplementedError() + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.hass.async_add_executor_job(self.set_native_value, value) + + @final def set_value(self, value: float) -> None: """Set new value.""" raise NotImplementedError() + @final async def async_set_value(self, value: float) -> None: """Set new value.""" await self.hass.async_add_executor_job(self.set_value, value) + + def _convert_to_state_value(self, value: float, method: Callable) -> float: + """Convert a value in the number's native unit to the configured unit.""" + + native_unit_of_measurement = self.native_unit_of_measurement + unit_of_measurement = self.unit_of_measurement + device_class = self.device_class + + if ( + native_unit_of_measurement != unit_of_measurement + and device_class in UNIT_CONVERSIONS + ): + assert native_unit_of_measurement + assert unit_of_measurement + + value_s = str(value) + prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + + # Suppress ValueError (Could not convert value to float) + with suppress(ValueError): + value_new: float = UNIT_CONVERSIONS[device_class]( + value, + native_unit_of_measurement, + unit_of_measurement, + ) + + # Round to the wanted precision + value = method(value_new, prec) + + return value + + def convert_to_native_value(self, value: float) -> float: + """Convert a value to the number's native unit.""" + + native_unit_of_measurement = self.native_unit_of_measurement + unit_of_measurement = self.unit_of_measurement + device_class = self.device_class + + if ( + value is not None + and native_unit_of_measurement != unit_of_measurement + and device_class in UNIT_CONVERSIONS + ): + assert native_unit_of_measurement + assert unit_of_measurement + + value = UNIT_CONVERSIONS[device_class]( + value, + unit_of_measurement, + native_unit_of_measurement, + ) + + return value + + def _report_deprecated_number_entity(self) -> None: + """Report that the number entity has not been upgraded.""" + if not self._deprecated_number_entity_reported: + self._deprecated_number_entity_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) is using deprecated NumberEntity features which will " + "be unsupported from Home Assistant Core 2022.10, please %s", + self.entity_id, + type(self), + report_issue, + ) + + +@dataclasses.dataclass +class NumberExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + native_max_value: float | None + native_min_value: float | None + native_step: float | None + native_unit_of_measurement: str | None + native_value: float | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the number data.""" + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> NumberExtraStoredData | None: + """Initialize a stored number state from a dict.""" + try: + return cls( + restored["native_max_value"], + restored["native_min_value"], + restored["native_step"], + restored["native_unit_of_measurement"], + restored["native_value"], + ) + except KeyError: + return None + + +class RestoreNumber(NumberEntity, RestoreEntity): + """Mixin class for restoring previous number state.""" + + @property + def extra_restore_state_data(self) -> NumberExtraStoredData: + """Return number specific state data to be restored.""" + return NumberExtraStoredData( + self.native_max_value, + self.native_min_value, + self.native_step, + self.native_unit_of_measurement, + self.native_value, + ) + + async def async_get_last_number_data(self) -> NumberExtraStoredData | None: + """Restore native_*.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return NumberExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 9f6b43974b7..64dc95d7b95 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -555,8 +555,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", @@ -564,8 +562,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, ), "watts": SensorEntityDescription( key="watts", diff --git a/homeassistant/components/nut/translations/bg.json b/homeassistant/components/nut/translations/bg.json index 4983c9a14b2..0ea2b4d6cb3 100644 --- a/homeassistant/components/nut/translations/bg.json +++ b/homeassistant/components/nut/translations/bg.json @@ -1,8 +1,12 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { + "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 44e1ed2d2c4..5bb1bb0bf6c 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -3,22 +3,18 @@ from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, - LENGTH_KILOMETERS, LENGTH_METERS, - LENGTH_MILES, - PRESSURE_HPA, - PRESSURE_INHG, PRESSURE_PA, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, @@ -28,9 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow -from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature @@ -155,68 +149,54 @@ class NWSWeather(WeatherEntity): return f"{self.station} {self.mode.title()}" @property - def temperature(self): + def native_temperature(self): """Return the current temperature.""" - temp_c = None if self.observation: - temp_c = self.observation.get("temperature") - if temp_c is not None: - return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) + return self.observation.get("temperature") return None @property - def pressure(self): + def native_temperature_unit(self): + """Return the current temperature unit.""" + return TEMP_CELSIUS + + @property + def native_pressure(self): """Return the current pressure.""" - pressure_pa = None if self.observation: - pressure_pa = self.observation.get("seaLevelPressure") - if pressure_pa is None: - return None - if self.is_metric: - pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) - pressure = round(pressure) - else: - pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_INHG) - pressure = round(pressure, 2) - return pressure + return self.observation.get("seaLevelPressure") + return None + + @property + def native_pressure_unit(self): + """Return the current pressure unit.""" + return PRESSURE_PA @property def humidity(self): """Return the name of the sensor.""" - humidity = None if self.observation: - humidity = self.observation.get("relativeHumidity") - return humidity + return self.observation.get("relativeHumidity") + return None @property - def wind_speed(self): + def native_wind_speed(self): """Return the current windspeed.""" - wind_km_hr = None if self.observation: - wind_km_hr = self.observation.get("windSpeed") - if wind_km_hr is None: - return None + return self.observation.get("windSpeed") + return None - if self.is_metric: - wind = wind_km_hr - else: - wind = convert_speed( - wind_km_hr, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR - ) - return round(wind) + @property + def native_wind_speed_unit(self): + """Return the current windspeed.""" + return SPEED_KILOMETERS_PER_HOUR @property def wind_bearing(self): """Return the current wind bearing (degrees).""" - wind_bearing = None if self.observation: - wind_bearing = self.observation.get("windDirection") - return wind_bearing - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT + return self.observation.get("windDirection") + return None @property def condition(self): @@ -232,19 +212,16 @@ class NWSWeather(WeatherEntity): return None @property - def visibility(self): + def native_visibility(self): """Return visibility.""" - vis_m = None if self.observation: - vis_m = self.observation.get("visibility") - if vis_m is None: - return None + return self.observation.get("visibility") + return None - if self.is_metric: - vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_KILOMETERS) - else: - vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_MILES) - return round(vis, 0) + @property + def native_visibility_unit(self): + """Return visibility unit.""" + return LENGTH_METERS @property def forecast(self): @@ -257,10 +234,16 @@ class NWSWeather(WeatherEntity): ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get( "detailedForecast" ), - ATTR_FORECAST_TEMP: forecast_entry.get("temperature"), ATTR_FORECAST_TIME: forecast_entry.get("startTime"), } + if (temp := forecast_entry.get("temperature")) is not None: + data[ATTR_FORECAST_NATIVE_TEMP] = convert_temperature( + temp, TEMP_FAHRENHEIT, TEMP_CELSIUS + ) + else: + data[ATTR_FORECAST_NATIVE_TEMP] = None + if self.mode == DAYNIGHT: data[ATTR_FORECAST_DAYTIME] = forecast_entry.get("isDaytime") time = forecast_entry.get("iconTime") @@ -275,16 +258,11 @@ class NWSWeather(WeatherEntity): data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing") wind_speed = forecast_entry.get("windSpeedAvg") if wind_speed is not None: - if self.is_metric: - data[ATTR_FORECAST_WIND_SPEED] = round( - convert_speed( - wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ) - ) - else: - data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed) + data[ATTR_FORECAST_NATIVE_WIND_SPEED] = convert_speed( + wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ) else: - data[ATTR_FORECAST_WIND_SPEED] = None + data[ATTR_FORECAST_NATIVE_WIND_SPEED] = None forecast.append(data) return forecast diff --git a/homeassistant/components/nx584/alarm_control_panel.py b/homeassistant/components/nx584/alarm_control_panel.py index 2ef664cb6d4..3eaaf07ad1c 100644 --- a/homeassistant/components/nx584/alarm_control_panel.py +++ b/homeassistant/components/nx584/alarm_control_panel.py @@ -55,9 +55,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the NX584 platform.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + name: str = config[CONF_NAME] + host: str = config[CONF_HOST] + port: int = config[CONF_PORT] url = f"http://{host}:{port}" @@ -92,34 +92,20 @@ async def async_setup_platform( class NX584Alarm(alarm.AlarmControlPanelEntity): """Representation of a NX584-based alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) - def __init__(self, name, alarm_client, url): + def __init__(self, name: str, alarm_client: client.Client, url: str) -> None: """Init the nx584 alarm panel.""" - self._name = name - self._state = None + self._attr_name = name self._alarm = alarm_client self._url = url - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def code_format(self): - """Return one or more digits/characters.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - - def update(self): + def update(self) -> None: """Process new events from panel.""" try: part = self._alarm.list_partitions()[0] @@ -129,11 +115,11 @@ class NX584Alarm(alarm.AlarmControlPanelEntity): "Unable to connect to %(host)s: %(reason)s", {"host": self._url, "reason": ex}, ) - self._state = None + self._attr_state = None zones = [] except IndexError: _LOGGER.error("NX584 reports no partitions") - self._state = None + self._attr_state = None zones = [] bypassed = False @@ -147,32 +133,32 @@ class NX584Alarm(alarm.AlarmControlPanelEntity): break if not part["armed"]: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif bypassed: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME else: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY for flag in part["condition_flags"]: if flag == "Siren on": - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._alarm.disarm(code) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._alarm.arm("stay") - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._alarm.arm("exit") - def alarm_bypass(self, zone): + def alarm_bypass(self, zone: int) -> None: """Send bypass command.""" self._alarm.set_bypass(zone, True) - def alarm_unbypass(self, zone): + def alarm_unbypass(self, zone: int) -> None: """Send bypass command.""" self._alarm.set_bypass(zone, False) diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index cbd1796b768..6fdea44f836 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -116,7 +116,10 @@ class NX584ZoneSensor(BinarySensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - return {"zone_number": self._zone["number"]} + return { + "zone_number": self._zone["number"], + "bypassed": self._zone.get("bypassed", False), + } class NX584Watcher(threading.Thread): diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index cb906495d58..a29ea829bbc 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -1,31 +1,18 @@ """The NZBGet integration.""" import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_SSL, - CONF_USERNAME, - Platform, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_SPEED, DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, - DEFAULT_NAME, - DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SPEED_LIMIT, - DEFAULT_SSL, DOMAIN, SERVICE_PAUSE, SERVICE_RESUME, @@ -35,54 +22,17 @@ from .coordinator import NZBGetDataUpdateCoordinator PLATFORMS = [Platform.SENSOR, Platform.SWITCH] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) SPEED_LIMIT_SCHEMA = vol.Schema( {vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.positive_int} ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the NZBGet integration.""" - hass.data.setdefault(DOMAIN, {}) - - if hass.config_entries.async_entries(DOMAIN): - return True - - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NZBGet from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + if not entry.options: options = { CONF_SCAN_INTERVAL: entry.data.get( diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index c7a1699a86c..732ef879762 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( @@ -33,7 +33,7 @@ from .coordinator import NZBGetAPI, NZBGetAPIException _LOGGER = logging.getLogger(__name__) -def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: +def _validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -49,8 +49,6 @@ def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: nzbget_api.version() - return True - class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for NZBGet.""" @@ -59,21 +57,10 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> NZBGetOptionsFlowHandler: """Get the options flow for this handler.""" return NZBGetOptionsFlowHandler(config_entry) - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow initiated by configuration file.""" - if CONF_SCAN_INTERVAL in user_input: - user_input[CONF_SCAN_INTERVAL] = user_input[ - CONF_SCAN_INTERVAL - ].total_seconds() - - return await self.async_step_user(user_input) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -88,9 +75,7 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL try: - await self.hass.async_add_executor_job( - validate_input, self.hass, user_input - ) + await self.hass.async_add_executor_job(_validate_input, user_input) except NZBGetAPIException: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except @@ -126,11 +111,13 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): class NZBGetOptionsFlowHandler(OptionsFlow): """Handle NZBGet client options.""" - def __init__(self, config_entry): + 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): + 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) diff --git a/homeassistant/components/nzbget/translations/sv.json b/homeassistant/components/nzbget/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/nzbget/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index e16f123a73a..0d403c3ec87 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -5,6 +5,7 @@ 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.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -54,7 +55,7 @@ class OctoprintButton(CoordinatorEntity[OctoprintDataUpdateCoordinator], ButtonE self._attr_unique_id = f"{button_type}-{device_id}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info.""" return self.coordinator.device_info diff --git a/homeassistant/components/octoprint/translations/sv.json b/homeassistant/components/octoprint/translations/sv.json new file mode 100644 index 00000000000..08b58e15cc6 --- /dev/null +++ b/homeassistant/components/octoprint/translations/sv.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "ssl": "Anv\u00e4nd SSL", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py index d5239760fcc..1635eaa7558 100644 --- a/homeassistant/components/omnilogic/config_flow.py +++ b/homeassistant/components/omnilogic/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Omnilogic integration.""" +from __future__ import annotations + import logging from omnilogic import LoginException, OmniLogic, OmniLogicException @@ -21,7 +23,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -71,7 +75,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle Omnilogic client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/omnilogic/translations/sv.json b/homeassistant/components/omnilogic/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/omnilogic/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 7f40ad87e84..c29fb7edf3a 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -156,9 +156,11 @@ class UserOnboardingView(_BaseOnboardingView): area_registry = ar.async_get(hass) for area in DEFAULT_AREAS: - area_registry.async_create( - translations[f"component.onboarding.area.{area}"] - ) + name = translations[f"component.onboarding.area.{area}"] + # Guard because area might have been created by an automatically + # set up integration. + if not area_registry.async_get_area_by_name(name): + area_registry.async_create(name) await self._async_mark_done(hass) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index c6f3d7dfa3f..b836d7e3298 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -6,6 +6,7 @@ from pyownet import protocol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import DOMAIN, PLATFORMS from .onewirehub import CannotConnect, OneWireHub @@ -17,16 +18,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" hass.data.setdefault(DOMAIN, {}) - onewirehub = OneWireHub(hass) + onewire_hub = OneWireHub(hass) try: - await onewirehub.initialize(entry) + await onewire_hub.initialize(entry) except ( CannotConnect, # Failed to connect to the server protocol.OwnetError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady() from exc - hass.data[DOMAIN][entry.entry_id] = onewirehub + hass.data[DOMAIN][entry.entry_id] = onewire_hub hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -35,6 +36,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + onewire_hub: OneWireHub = hass.data[DOMAIN][config_entry.entry_id] + return not device_entry.identifiers.intersection( + (DOMAIN, device.id) for device in onewire_hub.devices or [] + ) + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 307f38ceea0..4d6baef353e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -94,19 +94,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewirehub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub = hass.data[DOMAIN][config_entry.entry_id] - entities = await hass.async_add_executor_job(get_entities, onewirehub) + entities = await hass.async_add_executor_job(get_entities, onewire_hub) async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireBinarySensor]: +def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: """Get a list of entities.""" - if not onewirehub.devices: + if not onewire_hub.devices: return [] entities: list[OneWireBinarySensor] = [] - for device in onewirehub.devices: + for device in onewire_hub.devices: family = device.family device_id = device.id device_type = device.type @@ -128,7 +128,7 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBinarySensor]: device_file=device_file, device_info=device_info, name=name, - owproxy=onewirehub.owproxy, + owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py index a02ff2d8e47..36db7fd5360 100644 --- a/homeassistant/components/onewire/diagnostics.py +++ b/homeassistant/components/onewire/diagnostics.py @@ -19,7 +19,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - onewirehub: OneWireHub = hass.data[DOMAIN][entry.entry_id] + onewire_hub: OneWireHub = hass.data[DOMAIN][entry.entry_id] return { "entry": { @@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics( "data": async_redact_data(entry.data, TO_REDACT), "options": {**entry.options}, }, - "devices": [asdict(device_details) for device_details in onewirehub.devices] - if onewirehub.devices + "devices": [asdict(device_details) for device_details in onewire_hub.devices] + if onewire_hub.devices else [], } diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 9f376a6df7a..f6c88201dc7 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -367,23 +367,23 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewirehub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub = hass.data[DOMAIN][config_entry.entry_id] entities = await hass.async_add_executor_job( - get_entities, onewirehub, config_entry.options + get_entities, onewire_hub, config_entry.options ) async_add_entities(entities, True) def get_entities( - onewirehub: OneWireHub, options: MappingProxyType[str, Any] + onewire_hub: OneWireHub, options: MappingProxyType[str, Any] ) -> list[OneWireSensor]: """Get a list of entities.""" - if not onewirehub.devices: + if not onewire_hub.devices: return [] entities: list[OneWireSensor] = [] - assert onewirehub.owproxy - for device in onewirehub.devices: + assert onewire_hub.owproxy + for device in onewire_hub.devices: family = device.family device_type = device.type device_id = device.id @@ -403,7 +403,7 @@ def get_entities( if description.key.startswith("moisture/"): s_id = description.key.split(".")[1] is_leaf = int( - onewirehub.owproxy.read( + onewire_hub.owproxy.read( f"{device_path}moisture/is_leaf.{s_id}" ).decode() ) @@ -427,7 +427,7 @@ def get_entities( device_file=device_file, device_info=device_info, name=name, - owproxy=onewirehub.owproxy, + owproxy=onewire_hub.owproxy, ) ) return entities diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 764cc403681..8a6e6ff3736 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -150,20 +150,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewirehub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub = hass.data[DOMAIN][config_entry.entry_id] - entities = await hass.async_add_executor_job(get_entities, onewirehub) + entities = await hass.async_add_executor_job(get_entities, onewire_hub) async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireSwitch]: +def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: """Get a list of entities.""" - if not onewirehub.devices: + if not onewire_hub.devices: return [] entities: list[OneWireSwitch] = [] - for device in onewirehub.devices: + for device in onewire_hub.devices: family = device.family device_type = device.type device_id = device.id @@ -185,7 +185,7 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireSwitch]: device_file=device_file, device_info=device_info, name=name, - owproxy=onewirehub.owproxy, + owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onewire/translations/es.json b/homeassistant/components/onewire/translations/es.json index 9fa4912166f..3617f4c8fd4 100644 --- a/homeassistant/components/onewire/translations/es.json +++ b/homeassistant/components/onewire/translations/es.json @@ -25,11 +25,13 @@ "data": { "precision": "Precisi\u00f3n del sensor" }, - "description": "Selecciona la precisi\u00f3n del sensor {sensor_id}" + "description": "Selecciona la precisi\u00f3n del sensor {sensor_id}", + "title": "Precisi\u00f3n del sensor OneWire" }, "device_selection": { "data": { - "clear_device_options": "Borra todas las configuraciones de dispositivo" + "clear_device_options": "Borra todas las configuraciones de dispositivo", + "device_selection": "Seleccionar los dispositivos a configurar" }, "description": "Seleccione los pasos de configuraci\u00f3n a procesar", "title": "Opciones de dispositivo OneWire" diff --git a/homeassistant/components/onewire/translations/pt-BR.json b/homeassistant/components/onewire/translations/pt-BR.json index 303d36f3cb2..307ca800ba2 100644 --- a/homeassistant/components/onewire/translations/pt-BR.json +++ b/homeassistant/components/onewire/translations/pt-BR.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "host": "Host", + "host": "Nome do host", "port": "Porta" }, "title": "Definir detalhes do servidor" diff --git a/homeassistant/components/onewire/translations/sv.json b/homeassistant/components/onewire/translations/sv.json index 1b100c60d97..9b57beabc8f 100644 --- a/homeassistant/components/onewire/translations/sv.json +++ b/homeassistant/components/onewire/translations/sv.json @@ -3,6 +3,7 @@ "step": { "device_selection": { "data": { + "clear_device_options": "Rensa alla enhetskonfigurationer", "device_selection": "V\u00e4lj enheter att konfigurera" } } diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 1ee0be18467..48e5163ced5 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from pprint import pformat +from typing import Any from urllib.parse import urlparse from onvif.exceptions import ONVIFError @@ -44,7 +45,7 @@ def wsdiscovery() -> list[Service]: return services -async def async_discovery(hass) -> bool: +async def async_discovery(hass) -> list[dict[str, Any]]: """Return if there are devices that can be discovered.""" LOGGER.debug("Starting ONVIF discovery") services = await hass.async_add_executor_job(wsdiscovery) @@ -76,7 +77,9 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OnvifOptionsFlowHandler: """Get the options flow for this handler.""" return OnvifOptionsFlowHandler(config_entry) @@ -262,7 +265,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class OnvifOptionsFlowHandler(config_entries.OptionsFlow): """Handle ONVIF options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize ONVIF options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 3b4ae981677..3801d8081db 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -114,7 +114,7 @@ class EventManager: await self._subscription.Unsubscribe() self._subscription = None - async def async_restart(self, _now: dt = None) -> None: + async def async_restart(self, _now: dt.datetime | None = None) -> None: """Restart the subscription assuming the camera rebooted.""" if not self.started: return @@ -159,7 +159,7 @@ class EventManager: """Schedule async_pull_messages to run.""" self._unsub_refresh = async_call_later(self.hass, 1, self.async_pull_messages) - async def async_pull_messages(self, _now: dt = None) -> None: + async def async_pull_messages(self, _now: dt.datetime | None = None) -> None: """Pull messages from device.""" if self.hass.state == CoreState.running: try: diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index cd220500751..2df7c3004f0 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,7 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"], + "requirements": ["onvif-zeep-async==1.2.1", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true, diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py index dea613e3c1c..6cefa6332e2 100644 --- a/homeassistant/components/onvif/models.py +++ b/homeassistant/components/onvif/models.py @@ -11,11 +11,11 @@ from homeassistant.helpers.entity import EntityCategory class DeviceInfo: """Represent device information.""" - manufacturer: str = None - model: str = None - fw_version: str = None - serial_number: str = None - mac: str = None + manufacturer: str | None = None + model: str | None = None + fw_version: str | None = None + serial_number: str | None = None + mac: str | None = None @dataclass @@ -41,7 +41,7 @@ class PTZ: continuous: bool relative: bool absolute: bool - presets: list[str] = None + presets: list[str] | None = None @dataclass @@ -52,7 +52,7 @@ class Profile: token: str name: str video: Video - ptz: PTZ = None + ptz: PTZ | None = None @dataclass @@ -71,8 +71,8 @@ class Event: uid: str name: str platform: str - device_class: str = None - unit_of_measurement: str = None + device_class: str | None = None + unit_of_measurement: str | None = None value: Any = None entity_category: EntityCategory | None = None entity_enabled: bool = True diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 87b901d2c52..2c74f873f77 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -11,7 +11,9 @@ from homeassistant.util.decorator import Registry from .models import Event -PARSERS: Registry[str, Callable[[str, Any], Coroutine[Any, Any, Event]]] = Registry() +PARSERS: Registry[ + str, Callable[[str, Any], Coroutine[Any, Any, Event | None]] +] = Registry() def local_datetime_or_none(value: str) -> datetime.datetime | None: @@ -28,7 +30,7 @@ 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: +async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/MotionAlarm @@ -51,7 +53,7 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event: @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: +async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBlurry/* @@ -75,7 +77,7 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event: @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: +async def async_parse_image_too_dark(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooDark/* @@ -99,7 +101,7 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event: @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: +async def async_parse_image_too_bright(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBright/* @@ -123,7 +125,7 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event: @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: +async def async_parse_scene_change(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/GlobalSceneChange/* @@ -144,7 +146,7 @@ async def async_parse_scene_change(uid: str, msg) -> Event: @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") # pylint: disable=protected-access -async def async_parse_detected_sound(uid: str, msg) -> Event: +async def async_parse_detected_sound(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:AudioAnalytics/Audio/DetectedSound @@ -175,7 +177,7 @@ async def async_parse_detected_sound(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") # pylint: disable=protected-access -async def async_parse_field_detector(uid: str, msg) -> Event: +async def async_parse_field_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/FieldDetector/ObjectsInside @@ -207,7 +209,7 @@ async def async_parse_field_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") # pylint: disable=protected-access -async def async_parse_cell_motion_detector(uid: str, msg) -> Event: +async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/CellMotionDetector/Motion @@ -238,7 +240,7 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") # pylint: disable=protected-access -async def async_parse_motion_region_detector(uid: str, msg) -> Event: +async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/MotionRegionDetector/Motion @@ -269,7 +271,7 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") # pylint: disable=protected-access -async def async_parse_tamper_detector(uid: str, msg) -> Event: +async def async_parse_tamper_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/TamperDetector/Tamper @@ -301,7 +303,7 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:Device/Trigger/DigitalInput") # pylint: disable=protected-access -async def async_parse_digital_input(uid: str, msg) -> Event: +async def async_parse_digital_input(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/DigitalInput @@ -322,7 +324,7 @@ async def async_parse_digital_input(uid: str, msg) -> Event: @PARSERS.register("tns1:Device/Trigger/Relay") # pylint: disable=protected-access -async def async_parse_relay(uid: str, msg) -> Event: +async def async_parse_relay(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/Relay @@ -343,7 +345,7 @@ async def async_parse_relay(uid: str, msg) -> Event: @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") # pylint: disable=protected-access -async def async_parse_storage_failure(uid: str, msg) -> Event: +async def async_parse_storage_failure(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/HardwareFailure/StorageFailure @@ -365,7 +367,7 @@ async def async_parse_storage_failure(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/ProcessorUsage") # pylint: disable=protected-access -async def async_parse_processor_usage(uid: str, msg) -> Event: +async def async_parse_processor_usage(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/ProcessorUsage @@ -390,7 +392,7 @@ async def async_parse_processor_usage(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") # pylint: disable=protected-access -async def async_parse_last_reboot(uid: str, msg) -> Event: +async def async_parse_last_reboot(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReboot @@ -414,7 +416,7 @@ async def async_parse_last_reboot(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") # pylint: disable=protected-access -async def async_parse_last_reset(uid: str, msg) -> Event: +async def async_parse_last_reset(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReset @@ -439,7 +441,7 @@ async def async_parse_last_reset(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/Backup/Last") # pylint: disable=protected-access -async def async_parse_backup_last(uid: str, msg) -> Event: +async def async_parse_backup_last(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/Backup/Last @@ -465,7 +467,7 @@ async def async_parse_backup_last(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") # pylint: disable=protected-access -async def async_parse_last_clock_sync(uid: str, msg) -> Event: +async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization @@ -490,7 +492,7 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event: @PARSERS.register("tns1:RecordingConfig/JobState") # pylint: disable=protected-access -async def async_parse_jobstate(uid: str, msg) -> Event: +async def async_parse_jobstate(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RecordingConfig/JobState diff --git a/homeassistant/components/onvif/translations/sv.json b/homeassistant/components/onvif/translations/sv.json index 626daebc7b2..2cc40c1e465 100644 --- a/homeassistant/components/onvif/translations/sv.json +++ b/homeassistant/components/onvif/translations/sv.json @@ -1,6 +1,12 @@ { "config": { "step": { + "configure": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + }, "configure_profile": { "title": "Konfigurera Profiler" } diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 40b52248a52..d1f0adf2e87 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -5,7 +5,11 @@ from open_meteo import Forecast as OpenMeteoForecast from homeassistant.components.weather import Forecast, WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_MILLIMETERS, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -33,7 +37,9 @@ class OpenMeteoWeatherEntity( ): """Defines an Open-Meteo weather entity.""" - _attr_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR def __init__( self, @@ -63,14 +69,14 @@ class OpenMeteoWeatherEntity( ) @property - def temperature(self) -> float | None: + def native_temperature(self) -> float | None: """Return the platform temperature.""" if not self.coordinator.data.current_weather: return None return self.coordinator.data.current_weather.temperature @property - def wind_speed(self) -> float | None: + def native_wind_speed(self) -> float | None: """Return the wind speed.""" if not self.coordinator.data.current_weather: return None @@ -103,19 +109,19 @@ class OpenMeteoWeatherEntity( ) if daily.precipitation_sum is not None: - forecast["precipitation"] = daily.precipitation_sum[index] + forecast["native_precipitation"] = daily.precipitation_sum[index] if daily.temperature_2m_max is not None: - forecast["temperature"] = daily.temperature_2m_max[index] + forecast["native_temperature"] = daily.temperature_2m_max[index] if daily.temperature_2m_min is not None: - forecast["templow"] = daily.temperature_2m_min[index] + forecast["native_templow"] = daily.temperature_2m_min[index] if daily.wind_direction_10m_dominant is not None: forecast["wind_bearing"] = daily.wind_direction_10m_dominant[index] if daily.wind_speed_10m_max is not None: - forecast["wind_speed"] = daily.wind_speed_10m_max[index] + forecast["native_wind_speed"] = daily.wind_speed_10m_max[index] forecasts.append(forecast) diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index 504b83bdaf9..0272feb0f9e 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,7 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.21.6", "opencv-python-headless==4.5.2.54"], + "requirements": ["numpy==1.23.0", "opencv-python-headless==4.6.0.66"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index 9f953832674..3dcea4d0126 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_HOST, @@ -36,34 +37,43 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="charge_time", name="Charge Time Elapsed", native_unit_of_measurement=TIME_MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ambient_temp", name="Ambient Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ir_temp", name="IR Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="rtc_temp", name="RTC Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="usage_session", name="Usage this Session", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="usage_total", name="Total Usage", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), ) diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 9d5bd6e5cb6..aff913cf205 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -1,5 +1,8 @@ """Platform for the opengarage.io cover component.""" +from __future__ import annotations + import logging +from typing import Any from homeassistant.components.cover import ( CoverDeviceClass, @@ -42,27 +45,27 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): super().__init__(open_garage_data_coordinator, device_id) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._state is None: return None return self._state == STATE_CLOSED @property - def is_closing(self): + def is_closing(self) -> bool | None: """Return if the cover is closing.""" if self._state is None: return None return self._state == STATE_CLOSING @property - def is_opening(self): + def is_opening(self) -> bool | None: """Return if the cover is opening.""" if self._state is None: return None return self._state == STATE_OPENING - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" if self._state in [STATE_CLOSED, STATE_CLOSING]: return @@ -70,7 +73,7 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): self._state = STATE_CLOSING await self._push_button() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" if self._state in [STATE_OPEN, STATE_OPENING]: return diff --git a/homeassistant/components/opengarage/translations/sv.json b/homeassistant/components/opengarage/translations/sv.json new file mode 100644 index 00000000000..eba844f6c03 --- /dev/null +++ b/homeassistant/components/opengarage/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 7c3bc8f8f6b..1d66d6e2069 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -1,4 +1,6 @@ """OpenTherm Gateway config flow.""" +from __future__ import annotations + import asyncio import pyotgw @@ -34,7 +36,9 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OpenThermGwOptionsFlow: """Get the options flow for this handler.""" return OpenThermGwOptionsFlow(config_entry) @@ -111,7 +115,7 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OpenThermGwOptionsFlow(config_entries.OptionsFlow): """Handle opentherm_gw options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize the options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 0b7a3a1a25f..612965bdb2f 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -1,4 +1,6 @@ """Config flow for OpenWeatherMap.""" +from __future__ import annotations + from pyowm import OWM from pyowm.commons.exceptions import APIRequestError, UnauthorizedError import voluptuous as vol @@ -33,7 +35,9 @@ class OpenWeatherMapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OpenWeatherMapOptionsFlow: """Get the options flow for this handler.""" return OpenWeatherMapOptionsFlow(config_entry) @@ -89,7 +93,7 @@ class OpenWeatherMapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OpenWeatherMapOptionsFlow(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 8d673507929..836a56c70b2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -21,17 +21,10 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_PRESSURE, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, ) from homeassistant.const import ( DEGREE, - LENGTH_KILOMETERS, + LENGTH_METERS, LENGTH_MILLIMETERS, PERCENTAGE, PRESSURE_HPA, @@ -72,6 +65,16 @@ ATTR_API_FORECAST = "forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +ATTR_API_FORECAST_CONDITION = "condition" +ATTR_API_FORECAST_PRECIPITATION = "precipitation" +ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" +ATTR_API_FORECAST_PRESSURE = "pressure" +ATTR_API_FORECAST_TEMP = "temperature" +ATTR_API_FORECAST_TEMP_LOW = "templow" +ATTR_API_FORECAST_TIME = "datetime" +ATTR_API_FORECAST_WIND_BEARING = "wind_bearing" +ATTR_API_FORECAST_WIND_SPEED = "wind_speed" + FORECAST_MODE_HOURLY = "hourly" FORECAST_MODE_DAILY = "daily" FORECAST_MODE_FREE_DAILY = "freedaily" @@ -248,7 +251,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_VISIBILITY_DISTANCE, name="Visibility", - native_unit_of_measurement=LENGTH_KILOMETERS, + native_unit_of_measurement=LENGTH_METERS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -262,39 +265,39 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ) FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key=ATTR_FORECAST_CONDITION, + key=ATTR_API_FORECAST_CONDITION, name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION, + key=ATTR_API_FORECAST_PRECIPITATION, name="Precipitation", native_unit_of_measurement=LENGTH_MILLIMETERS, ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION_PROBABILITY, + key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, name="Precipitation probability", native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_PRESSURE, + key=ATTR_API_FORECAST_PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP, + key=ATTR_API_FORECAST_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP_LOW, + key=ATTR_API_FORECAST_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TIME, + key=ATTR_API_FORECAST_TIME, name="Time", device_class=SensorDeviceClass.TIMESTAMP, ), diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index d4ab99bc30b..ea439a35586 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -1,18 +1,43 @@ """Support for the OpenWeatherMap (OWM) service.""" from __future__ import annotations -from homeassistant.components.weather import Forecast, WeatherEntity +from typing import cast + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + Forecast, + WeatherEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRESSURE_HPA, PRESSURE_INHG, TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.pressure import convert as pressure_convert from .const import ( ATTR_API_CONDITION, ATTR_API_FORECAST, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, @@ -27,6 +52,17 @@ from .const import ( ) from .weather_update_coordinator import WeatherUpdateCoordinator +FORECAST_MAP = { + ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, + ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, +} + async def async_setup_entry( hass: HomeAssistant, @@ -49,7 +85,11 @@ class OpenWeatherMapWeather(WeatherEntity): _attr_attribution = ATTRIBUTION _attr_should_poll = False - _attr_temperature_unit = TEMP_CELSIUS + + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__( self, @@ -74,19 +114,14 @@ class OpenWeatherMapWeather(WeatherEntity): return self._weather_coordinator.data[ATTR_API_CONDITION] @property - def temperature(self) -> float | None: + def native_temperature(self) -> float | None: """Return the temperature.""" return self._weather_coordinator.data[ATTR_API_TEMPERATURE] @property - def pressure(self) -> float | None: + def native_pressure(self) -> float | None: """Return the pressure.""" - pressure = self._weather_coordinator.data[ATTR_API_PRESSURE] - # OpenWeatherMap returns pressure in hPA, so convert to - # inHg if we aren't using metric. - if not self.hass.config.units.is_metric and pressure: - return pressure_convert(pressure, PRESSURE_HPA, PRESSURE_INHG) - return pressure + return self._weather_coordinator.data[ATTR_API_PRESSURE] @property def humidity(self) -> float | None: @@ -94,12 +129,9 @@ class OpenWeatherMapWeather(WeatherEntity): return self._weather_coordinator.data[ATTR_API_HUMIDITY] @property - def wind_speed(self) -> float | None: + def native_wind_speed(self) -> float | None: """Return the wind speed.""" - wind_speed = self._weather_coordinator.data[ATTR_API_WIND_SPEED] - if self.hass.config.units.name == "imperial": - return round(wind_speed * 2.24, 2) - return round(wind_speed * 3.6, 2) + return self._weather_coordinator.data[ATTR_API_WIND_SPEED] @property def wind_bearing(self) -> float | str | None: @@ -109,7 +141,12 @@ class OpenWeatherMapWeather(WeatherEntity): @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" - return self._weather_coordinator.data[ATTR_API_FORECAST] + api_forecasts = self._weather_coordinator.data[ATTR_API_FORECAST] + forecasts = [ + {ha_key: forecast[api_key] for api_key, ha_key in FORECAST_MAP.items()} + for forecast in api_forecasts + ] + return cast(list[Forecast], forecasts) @property def available(self) -> bool: diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 26341621051..98c3b56fb5e 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -8,15 +8,6 @@ from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_PRESSURE, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, ) from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,6 +20,15 @@ from .const import ( ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FORECAST, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_PRESSURE, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, @@ -158,19 +158,19 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _convert_forecast(self, entry): """Convert the forecast data.""" forecast = { - ATTR_FORECAST_TIME: dt.utc_from_timestamp( + ATTR_API_FORECAST_TIME: dt.utc_from_timestamp( entry.reference_time("unix") ).isoformat(), - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation( + ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation( entry.rain, entry.snow ), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ( round(entry.precipitation_probability * 100) ), - ATTR_FORECAST_PRESSURE: entry.pressure.get("press"), - ATTR_FORECAST_WIND_SPEED: entry.wind().get("speed"), - ATTR_FORECAST_WIND_BEARING: entry.wind().get("deg"), - ATTR_FORECAST_CONDITION: self._get_condition( + ATTR_API_FORECAST_PRESSURE: entry.pressure.get("press"), + ATTR_API_FORECAST_WIND_SPEED: entry.wind().get("speed"), + ATTR_API_FORECAST_WIND_BEARING: entry.wind().get("deg"), + ATTR_API_FORECAST_CONDITION: self._get_condition( entry.weather_code, entry.reference_time("unix") ), ATTR_API_CLOUDS: entry.clouds, @@ -178,10 +178,12 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): temperature_dict = entry.temperature("celsius") if "max" in temperature_dict and "min" in temperature_dict: - forecast[ATTR_FORECAST_TEMP] = entry.temperature("celsius").get("max") - forecast[ATTR_FORECAST_TEMP_LOW] = entry.temperature("celsius").get("min") + forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("max") + forecast[ATTR_API_FORECAST_TEMP_LOW] = entry.temperature("celsius").get( + "min" + ) else: - forecast[ATTR_FORECAST_TEMP] = entry.temperature("celsius").get("temp") + forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("temp") return forecast diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index 0e98b7c7e21..737ea342c40 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -2,7 +2,13 @@ from pyoverkiz.enums.ui import UIWidget from .atlantic_electrical_heater import AtlanticElectricalHeater +from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer +from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl +from .somfy_thermostat import SomfyThermostat WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, + UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer, + UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, + UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, } diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py new file mode 100644 index 00000000000..9a24a7bf1a9 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -0,0 +1,122 @@ +"""Support for Atlantic Electrical Towel Dryer.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + PRESET_BOOST, + PRESET_NONE, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.overkiz.coordinator import OverkizDataUpdateCoordinator +from homeassistant.components.overkiz.entity import OverkizEntity +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +PRESET_DRYING = "drying" + +OVERKIZ_TO_HVAC_MODE: dict[str, str] = { + OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu + OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog + OverkizCommandParam.STANDBY: HVACMode.OFF, +} +HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} + +OVERKIZ_TO_PRESET_MODE: dict[str, str] = { + OverkizCommandParam.PERMANENT_HEATING: PRESET_NONE, + OverkizCommandParam.BOOST: PRESET_BOOST, + OverkizCommandParam.DRYING: PRESET_DRYING, +} + +PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} + +TEMPERATURE_SENSOR_DEVICE_INDEX = 7 + + +class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): + """Representation of Atlantic Electrical Towel Dryer.""" + + _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_temperature_unit = TEMP_CELSIUS + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + # Not all AtlanticElectricalTowelDryer models support presets, thus we need to check if the command is available + if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE): + self._attr_supported_features += ClimateEntityFeature.PRESET_MODE + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if OverkizState.CORE_OPERATING_MODE in self.device.states: + return OVERKIZ_TO_HVAC_MODE[ + cast(str, self.executor.select_state(OverkizState.CORE_OPERATING_MODE)) + ] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + HVAC_MODE_TO_OVERKIZ[hvac_mode], + ) + + @property + def target_temperature(self) -> None: + """Return the temperature.""" + if self.hvac_mode == HVACMode.AUTO: + self.executor.select_state(OverkizState.IO_EFFECTIVE_TEMPERATURE_SETPOINT) + else: + self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return cast(float, temperature.value) + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + if self.hvac_mode == HVACMode.AUTO: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE, temperature + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_TARGET_TEMPERATURE, temperature + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, + self.executor.select_state(OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE), + ) + ] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + PRESET_MODE_TO_OVERKIZ[preset_mode], + ) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py new file mode 100644 index 00000000000..fc1d909390b --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -0,0 +1,40 @@ +"""Support for Atlantic Pass APC Zone Control.""" +from typing import cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import HVACMode +from homeassistant.components.overkiz.entity import OverkizEntity +from homeassistant.const import TEMP_CELSIUS + +OVERKIZ_TO_HVAC_MODE: dict[str, str] = { + OverkizCommandParam.HEATING: HVACMode.HEAT, + OverkizCommandParam.DRYING: HVACMode.DRY, + OverkizCommandParam.COOLING: HVACMode.COOL, + OverkizCommandParam.STOP: HVACMode.OFF, +} + +HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} + + +class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): + """Representation of Atlantic Pass APC Zone Control.""" + + _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + _attr_temperature_unit = TEMP_CELSIUS + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return OVERKIZ_TO_HVAC_MODE[ + cast( + str, self.executor.select_state(OverkizState.IO_PASS_APC_OPERATING_MODE) + ) + ] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] + ) diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py new file mode 100644 index 00000000000..80859d7561b --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -0,0 +1,162 @@ +"""Support for Somfy Smart Thermostat.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + PRESET_AWAY, + PRESET_HOME, + PRESET_NONE, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_FREEZE = "freeze" +PRESET_NIGHT = "night" + +STATE_DEROGATION_ACTIVE = "active" +STATE_DEROGATION_INACTIVE = "inactive" + + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + STATE_DEROGATION_ACTIVE: HVACMode.HEAT, + STATE_DEROGATION_INACTIVE: HVACMode.AUTO, +} +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = { + OverkizCommandParam.AT_HOME_MODE: PRESET_HOME, + OverkizCommandParam.AWAY_MODE: PRESET_AWAY, + OverkizCommandParam.FREEZE_MODE: PRESET_FREEZE, + OverkizCommandParam.MANUAL_MODE: PRESET_NONE, + OverkizCommandParam.SLEEPING_MODE: PRESET_NIGHT, + OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE, +} +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} +TARGET_TEMP_TO_OVERKIZ = { + PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE, + PRESET_AWAY: OverkizState.SOMFY_THERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE, + PRESET_FREEZE: OverkizState.SOMFY_THERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE, + PRESET_NIGHT: OverkizState.SOMFY_THERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE, +} + +# controllableName is somfythermostat:SomfyThermostatTemperatureSensor +TEMPERATURE_SENSOR_DEVICE_INDEX = 2 + + +class SomfyThermostat(OverkizEntity, ClimateEntity): + """Representation of Somfy Smart Thermostat.""" + + _attr_temperature_unit = TEMP_CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + # Both min and max temp values have been retrieved from the Somfy Application. + _attr_min_temp = 15.0 + _attr_max_temp = 26.0 + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return OVERKIZ_TO_HVAC_MODES[ + cast( + str, self.executor.select_state(OverkizState.CORE_DEROGATION_ACTIVATION) + ) + ] + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp.""" + if self.hvac_mode == HVACMode.AUTO: + state_key = OverkizState.SOMFY_THERMOSTAT_HEATING_MODE + else: + state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE + + state = cast(str, self.executor.select_state(state_key)) + + return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(state)] + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return cast(float, temperature.value) + return None + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + if self.hvac_mode == HVACMode.AUTO: + if self.preset_mode == PRESET_NONE: + return None + return cast( + float, + self.executor.select_state(TARGET_TEMP_TO_OVERKIZ[self.preset_mode]), + ) + return cast( + float, + self.executor.select_state(OverkizState.CORE_DEROGATED_TARGET_TEMPERATURE), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + temperature, + OverkizCommandParam.FURTHER_NOTICE, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_MODE_TEMPERATURE, + OverkizCommandParam.MANUAL_MODE, + temperature, + ) + await self.executor.async_execute_command(OverkizCommand.REFRESH_STATE) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.AUTO: + await self.executor.async_execute_command(OverkizCommand.EXIT_DEROGATION) + await self.executor.async_execute_command(OverkizCommand.REFRESH_STATE) + else: + await self.async_set_preset_mode(PRESET_NONE) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode in [PRESET_FREEZE, PRESET_NIGHT, PRESET_AWAY, PRESET_HOME]: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + PRESET_MODES_TO_OVERKIZ[preset_mode], + OverkizCommandParam.FURTHER_NOTICE, + ) + elif preset_mode == PRESET_NONE: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + self.target_temperature, + OverkizCommandParam.FURTHER_NOTICE, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_MODE_TEMPERATURE, + OverkizCommandParam.MANUAL_MODE, + self.target_temperature, + ) + await self.executor.async_execute_command(OverkizCommand.REFRESH_STATE) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 479e212e317..2808c309938 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Overkiz (by Somfy) integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any, cast from aiohttp import ClientError @@ -154,9 +155,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._config_entry = cast( ConfigEntry, @@ -170,4 +169,4 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._default_user = self._config_entry.data[CONF_USERNAME] self._default_hub = self._config_entry.data[CONF_HUB] - return await self.async_step_user(user_input) + return await self.async_step_user(dict(entry_data)) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 8488103a238..9091cd35998 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -62,6 +62,8 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIClass.WINDOW: Platform.COVER, UIWidget.ALARM_PANEL_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.ATLANTIC_ELECTRICAL_HEATER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, # widgetName, uiClass is Camera (not supported) @@ -69,6 +71,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.RTD_OUTDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) UIWidget.RTS_GENERIC: Platform.COVER, # widgetName, uiClass is Generic (not supported) UIWidget.SIREN_STATUS: None, # widgetName, uiClass is Siren (siren) + UIWidget.SOMFY_THERMOSTAT: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, # widgetName, uiClass is Alarm (not supported) UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index a177766c292..5728349c5d0 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -118,3 +118,4 @@ class OverkizDeviceClass(StrEnum): PRIORITY_LOCK_ORIGINATOR = "overkiz__priority_lock_originator" SENSOR_DEFECT = "overkiz__sensor_defect" SENSOR_ROOM = "overkiz__sensor_room" + THREE_WAY_HANDLE_DIRECTION = "overkiz__three_way_handle_direction" diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 9bf7ef43b02..e82a6e21f63 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -37,6 +37,10 @@ class OverkizExecutor: """Return Overkiz device linked to this entity.""" return self.coordinator.data[self.device_url] + def linked_device(self, index: int) -> Device: + """Return Overkiz device sharing the same base url.""" + return self.coordinator.data[f"{self.base_device_url}#{index}"] + def select_command(self, *commands: str) -> str | None: """Select first existing command in a list of commands.""" existing_commands = self.device.definition.commands diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index c81de1e6139..a7595065224 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -3,7 +3,7 @@ "name": "Overkiz (by Somfy)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/overkiz", - "requirements": ["pyoverkiz==1.4.0"], + "requirements": ["pyoverkiz==1.4.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", @@ -24,6 +24,7 @@ "flexom": "Bouygues Flexom", "hi_kumo": "Hitachi Hi Kumo", "nexity": "Nexity Eugénie", - "rexel": "Rexel Energeasy Connect" + "rexel": "Rexel Energeasy Connect", + "somfy": "Somfy" } } diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 167065e9015..8e7d2a93ee9 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -6,8 +6,13 @@ from typing import cast from pyoverkiz.enums import OverkizCommand, OverkizState -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 TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,8 +43,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="My Position", icon="mdi:content-save-cog", command=OverkizCommand.SET_MEMORIZED_1_POSITION, - min_value=0, - max_value=100, + native_min_value=0, + native_max_value=100, entity_category=EntityCategory.CONFIG, ), # WaterHeater: Expected Number Of Shower (2 - 4) @@ -48,8 +53,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Expected Number Of Shower", icon="mdi:shower-head", command=OverkizCommand.SET_EXPECTED_NUMBER_OF_SHOWER, - min_value=2, - max_value=4, + native_min_value=2, + native_max_value=4, entity_category=EntityCategory.CONFIG, ), # SomfyHeatingTemperatureInterface @@ -58,8 +63,10 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Eco Room Temperature", icon="mdi:thermometer", command=OverkizCommand.SET_ECO_TEMPERATURE, - min_value=6, - max_value=29, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=6, + native_max_value=29, + native_unit_of_measurement=TEMP_CELSIUS, entity_category=EntityCategory.CONFIG, ), OverkizNumberDescription( @@ -67,8 +74,10 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Comfort Room Temperature", icon="mdi:home-thermometer-outline", command=OverkizCommand.SET_COMFORT_TEMPERATURE, - min_value=7, - max_value=30, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=7, + native_max_value=30, + native_unit_of_measurement=TEMP_CELSIUS, entity_category=EntityCategory.CONFIG, ), OverkizNumberDescription( @@ -76,8 +85,10 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Freeze Protection Temperature", icon="mdi:sun-thermometer-outline", command=OverkizCommand.SET_SECURED_POSITION_TEMPERATURE, - min_value=5, - max_value=15, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=5, + native_max_value=15, + native_unit_of_measurement=TEMP_CELSIUS, entity_category=EntityCategory.CONFIG, ), # DimmerExteriorHeating (Somfy Terrace Heater) (0 - 100) @@ -86,8 +97,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ key=OverkizState.CORE_LEVEL, icon="mdi:patio-heater", command=OverkizCommand.SET_LEVEL, - min_value=0, - max_value=100, + native_min_value=0, + native_max_value=100, inverted=True, ), ] @@ -130,20 +141,20 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): entity_description: OverkizNumberDescription @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" if state := self.device.states.get(self.entity_description.key): if self.entity_description.inverted: - return self.max_value - cast(float, state.value) + return self.native_max_value - cast(float, state.value) return cast(float, state.value) return None - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" if self.entity_description.inverted: - value = self.max_value - value + value = self.native_max_value - value await self.executor.async_execute_command( self.entity_description.command, value diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 74d3b3ba282..8482932e2e4 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -50,6 +50,17 @@ def _select_option_memorized_simple_volume( return execute_command(OverkizCommand.SET_MEMORIZED_SIMPLE_VOLUME, option) +def _select_option_active_zone( + option: str, execute_command: Callable[..., Awaitable[None]] +) -> Awaitable[None]: + """Change the selected option for Active Zone(s).""" + # Turn alarm off when empty zone is selected + if option == "": + return execute_command(OverkizCommand.ALARM_OFF) + + return execute_command(OverkizCommand.ALARM_ZONE_ON, option) + + SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, @@ -83,6 +94,14 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ ), entity_category=EntityCategory.CONFIG, ), + # StatefulAlarmController + OverkizSelectDescription( + key=OverkizState.CORE_ACTIVE_ZONES, + name="Active Zones", + icon="mdi:shield-lock", + options=["", "A", "B", "C", "A,B", "B,C", "A,C", "A,B,C"], + select_option=_select_option_active_zone, + ), ] SUPPORTED_STATES = {description.key: description for description in SELECT_DESCRIPTIONS} diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 10de6f699dd..ac32c76c459 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -366,6 +366,12 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, ), + # ThreeWayWindowHandle/WindowHandle + OverkizSensorDescription( + key=OverkizState.CORE_THREE_WAY_HANDLE_DIRECTION, + name="Three Way Handle Direction", + device_class=OverkizDeviceClass.THREE_WAY_HANDLE_DIRECTION, + ), ] SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCRIPTIONS} diff --git a/homeassistant/components/overkiz/strings.sensor.json b/homeassistant/components/overkiz/strings.sensor.json index 4df83bcad77..fdeaa5b911b 100644 --- a/homeassistant/components/overkiz/strings.sensor.json +++ b/homeassistant/components/overkiz/strings.sensor.json @@ -36,6 +36,11 @@ "low_battery": "Low battery", "maintenance_required": "Maintenance required", "no_defect": "No defect" + }, + "overkiz__three_way_handle_direction": { + "closed": "Closed", + "open": "Open", + "tilt": "Tilt" } } } diff --git a/homeassistant/components/overkiz/translations/sensor.ca.json b/homeassistant/components/overkiz/translations/sensor.ca.json index 2d94ab16d1a..9e1a40941b0 100644 --- a/homeassistant/components/overkiz/translations/sensor.ca.json +++ b/homeassistant/components/overkiz/translations/sensor.ca.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Net", "dirty": "Brut" + }, + "overkiz__three_way_handle_direction": { + "closed": "Tancada", + "open": "Oberta", + "tilt": "Inclinada" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.de.json b/homeassistant/components/overkiz/translations/sensor.de.json index 8f262514e5f..216df8f0cff 100644 --- a/homeassistant/components/overkiz/translations/sensor.de.json +++ b/homeassistant/components/overkiz/translations/sensor.de.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Sauber", "dirty": "Schmutzig" + }, + "overkiz__three_way_handle_direction": { + "closed": "Geschlossen", + "open": "Offen", + "tilt": "Kippen" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.el.json b/homeassistant/components/overkiz/translations/sensor.el.json index 445a457195c..5335b9d8049 100644 --- a/homeassistant/components/overkiz/translations/sensor.el.json +++ b/homeassistant/components/overkiz/translations/sensor.el.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "\u039a\u03b1\u03b8\u03b1\u03c1\u03cc\u03c2", "dirty": "\u0392\u03c1\u03ce\u03bc\u03b9\u03ba\u03bf\u03c2" + }, + "overkiz__three_way_handle_direction": { + "closed": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "open": "\u0386\u03bd\u03bf\u03b9\u03b3\u03bc\u03b1", + "tilt": "\u039a\u03bb\u03af\u03c3\u03b7" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.en.json b/homeassistant/components/overkiz/translations/sensor.en.json index c0eef6b3ef6..13a10f9a072 100644 --- a/homeassistant/components/overkiz/translations/sensor.en.json +++ b/homeassistant/components/overkiz/translations/sensor.en.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Clean", "dirty": "Dirty" + }, + "overkiz__three_way_handle_direction": { + "closed": "Closed", + "open": "Open", + "tilt": "Tilt" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.et.json b/homeassistant/components/overkiz/translations/sensor.et.json index 974d57b095c..72021d675c8 100644 --- a/homeassistant/components/overkiz/translations/sensor.et.json +++ b/homeassistant/components/overkiz/translations/sensor.et.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Puhas", "dirty": "R\u00e4pane" + }, + "overkiz__three_way_handle_direction": { + "closed": "Suletud", + "open": "Avatud", + "tilt": "Kallutamine" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.fr.json b/homeassistant/components/overkiz/translations/sensor.fr.json index af9fd658ab9..23fe8993570 100644 --- a/homeassistant/components/overkiz/translations/sensor.fr.json +++ b/homeassistant/components/overkiz/translations/sensor.fr.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Propre", "dirty": "Sale" + }, + "overkiz__three_way_handle_direction": { + "closed": "Ferm\u00e9e", + "open": "Ouverte", + "tilt": "Inclin\u00e9e" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.hu.json b/homeassistant/components/overkiz/translations/sensor.hu.json index a361d0ebde2..5646f6bdd7a 100644 --- a/homeassistant/components/overkiz/translations/sensor.hu.json +++ b/homeassistant/components/overkiz/translations/sensor.hu.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Tiszta", "dirty": "Piszkos" + }, + "overkiz__three_way_handle_direction": { + "closed": "Z\u00e1rva", + "open": "Nyitva", + "tilt": "D\u00f6nt\u00e9s" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.id.json b/homeassistant/components/overkiz/translations/sensor.id.json index efe4f588a71..bf4703507f8 100644 --- a/homeassistant/components/overkiz/translations/sensor.id.json +++ b/homeassistant/components/overkiz/translations/sensor.id.json @@ -36,6 +36,10 @@ "overkiz__sensor_room": { "clean": "Bersih", "dirty": "Kotor" + }, + "overkiz__three_way_handle_direction": { + "closed": "Tutup", + "open": "Buka" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.it.json b/homeassistant/components/overkiz/translations/sensor.it.json index cb63ec30408..0799ae8d7c8 100644 --- a/homeassistant/components/overkiz/translations/sensor.it.json +++ b/homeassistant/components/overkiz/translations/sensor.it.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Pulito", "dirty": "Sporco" + }, + "overkiz__three_way_handle_direction": { + "closed": "Chiuso", + "open": "Aperto", + "tilt": "Inclinazione" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.ja.json b/homeassistant/components/overkiz/translations/sensor.ja.json index ca1f1833a0a..e1c44a8e3ff 100644 --- a/homeassistant/components/overkiz/translations/sensor.ja.json +++ b/homeassistant/components/overkiz/translations/sensor.ja.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "\u30af\u30ea\u30fc\u30f3", "dirty": "\u30c0\u30fc\u30c6\u30a3" + }, + "overkiz__three_way_handle_direction": { + "closed": "\u30af\u30ed\u30fc\u30ba\u30c9", + "open": "\u30aa\u30fc\u30d7\u30f3", + "tilt": "\u50be\u659c(Tilt)" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.nl.json b/homeassistant/components/overkiz/translations/sensor.nl.json index aef0b1e0394..8254719cf59 100644 --- a/homeassistant/components/overkiz/translations/sensor.nl.json +++ b/homeassistant/components/overkiz/translations/sensor.nl.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Schoon", "dirty": "Vuil" + }, + "overkiz__three_way_handle_direction": { + "closed": "Gesloten", + "open": "Open", + "tilt": "Kantelen" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.no.json b/homeassistant/components/overkiz/translations/sensor.no.json index 23a94df54c7..65f3cbeed9f 100644 --- a/homeassistant/components/overkiz/translations/sensor.no.json +++ b/homeassistant/components/overkiz/translations/sensor.no.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Ren", "dirty": "Skitten" + }, + "overkiz__three_way_handle_direction": { + "closed": "Lukket", + "open": "\u00c5pen", + "tilt": "Vippe" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.pl.json b/homeassistant/components/overkiz/translations/sensor.pl.json index 0633c0d8424..440cf4998f8 100644 --- a/homeassistant/components/overkiz/translations/sensor.pl.json +++ b/homeassistant/components/overkiz/translations/sensor.pl.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "czysto", "dirty": "brudno" + }, + "overkiz__three_way_handle_direction": { + "closed": "zamkni\u0119ta", + "open": "otwarta", + "tilt": "uchylona" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.pt-BR.json b/homeassistant/components/overkiz/translations/sensor.pt-BR.json index 3ec82aa83d5..0f391de9027 100644 --- a/homeassistant/components/overkiz/translations/sensor.pt-BR.json +++ b/homeassistant/components/overkiz/translations/sensor.pt-BR.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Limpo", "dirty": "Sujo" + }, + "overkiz__three_way_handle_direction": { + "closed": "Fechado", + "open": "Aberto", + "tilt": "Inclinar" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.sv.json b/homeassistant/components/overkiz/translations/sensor.sv.json new file mode 100644 index 00000000000..024e5fe1037 --- /dev/null +++ b/homeassistant/components/overkiz/translations/sensor.sv.json @@ -0,0 +1,9 @@ +{ + "state": { + "overkiz__three_way_handle_direction": { + "closed": "St\u00e4ngd", + "open": "\u00d6ppen", + "tilt": "Vinkla" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.uk.json b/homeassistant/components/overkiz/translations/sensor.uk.json new file mode 100644 index 00000000000..cf84368e911 --- /dev/null +++ b/homeassistant/components/overkiz/translations/sensor.uk.json @@ -0,0 +1,9 @@ +{ + "state": { + "overkiz__three_way_handle_direction": { + "closed": "\u0417\u0430\u043a\u0440\u0438\u0442\u043e", + "open": "\u0412\u0456\u0434\u043a\u0440\u0438\u0442\u043e", + "tilt": "\u041d\u0430\u0445\u0438\u043b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.zh-Hant.json b/homeassistant/components/overkiz/translations/sensor.zh-Hant.json index 785c02cbfbc..661dfa5d313 100644 --- a/homeassistant/components/overkiz/translations/sensor.zh-Hant.json +++ b/homeassistant/components/overkiz/translations/sensor.zh-Hant.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "\u826f\u597d", "dirty": "\u4e0d\u4f73" + }, + "overkiz__three_way_handle_direction": { + "closed": "\u95dc\u9589", + "open": "\u958b\u555f", + "tilt": "\u50be\u659c" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sv.json b/homeassistant/components/overkiz/translations/sv.json index 5ba4512fb35..c825e3cd616 100644 --- a/homeassistant/components/overkiz/translations/sv.json +++ b/homeassistant/components/overkiz/translations/sv.json @@ -3,6 +3,13 @@ "abort": { "reauth_successful": "\u00c5terautentisering lyckades", "reauth_wrong_account": "Du kan bara \u00e5terautentisera denna post med samma Overkiz-konto och hub" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/ovo_energy/translations/sv.json b/homeassistant/components/ovo_energy/translations/sv.json new file mode 100644 index 00000000000..054280346d3 --- /dev/null +++ b/homeassistant/components/ovo_energy/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "reauth": { + "data": { + "password": "L\u00f6senord" + } + }, + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/bg.json b/homeassistant/components/panasonic_viera/translations/bg.json index 9dc5d863d85..2ceff63752c 100644 --- a/homeassistant/components/panasonic_viera/translations/bg.json +++ b/homeassistant/components/panasonic_viera/translations/bg.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "error": { @@ -18,7 +19,8 @@ "data": { "host": "IP \u0430\u0434\u0440\u0435\u0441", "name": "\u0418\u043c\u0435" - } + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0432\u0430\u0448\u0438\u044f \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440" } } } diff --git a/homeassistant/components/panasonic_viera/translations/sv.json b/homeassistant/components/panasonic_viera/translations/sv.json index f70336fae9f..2c863b587ec 100644 --- a/homeassistant/components/panasonic_viera/translations/sv.json +++ b/homeassistant/components/panasonic_viera/translations/sv.json @@ -4,6 +4,11 @@ "unknown": "Ett ov\u00e4ntat fel intr\u00e4ffade. Kontrollera loggarna f\u00f6r mer information." }, "step": { + "pairing": { + "data": { + "pin": "Pin-kod" + } + }, "user": { "data": { "host": "IP-adress", diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index 6f88bf36c50..86f69213a1c 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -1,7 +1,6 @@ """The PECO Outage Counter integration.""" from __future__ import annotations -import asyncio from dataclasses import dataclass from datetime import timedelta from typing import Final @@ -48,8 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(f"Error fetching data: {err}") from err except BadJSONError as err: raise UpdateFailed(f"Error parsing data: {err}") from err - except asyncio.TimeoutError as err: - raise UpdateFailed(f"Timeout fetching data: {err}") from err return data coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index dfd354f5c03..5afc300bfa8 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -93,10 +93,8 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - [PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST], - True, + PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST ) - return class PecoSensor( diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 9a317726768..29e92a6ffe3 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -19,14 +19,7 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import ( - CALLBACK_TYPE, - Context, - Event, - HassJob, - HomeAssistant, - callback, -) +from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -121,12 +114,7 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): self.options = options self._notify_future: asyncio.Task | None = None - @callback - def _update_listeners(): - for update_callback in self._listeners: - update_callback() - - self.turn_on = PluggableAction(_update_listeners) + self.turn_on = PluggableAction(self.async_update_listeners) super().__init__( hass, @@ -193,15 +181,9 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): self._notify_future = asyncio.create_task(self._notify_task()) @callback - def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: + def _unschedule_refresh(self) -> None: """Remove data update.""" - super().async_remove_listener(update_callback) - if not self._listeners: - self._async_notify_stop() - - @callback - def _async_stop_refresh(self, event: Event) -> None: - super()._async_stop_refresh(event) + super()._unschedule_refresh() self._async_notify_stop() @callback diff --git a/homeassistant/components/pi_hole/translations/sv.json b/homeassistant/components/pi_hole/translations/sv.json new file mode 100644 index 00000000000..0459a7596f3 --- /dev/null +++ b/homeassistant/components/pi_hole/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "api_key": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel", + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index c2d48ca9415..904b68e3d32 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -1,7 +1,9 @@ """Config flow for Picnic integration.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from python_picnic_api import PicnicAPI from python_picnic_api.session import PicnicAuthError @@ -11,6 +13,7 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from .const import CONF_COUNTRY_CODE, COUNTRY_CODES, DOMAIN @@ -72,7 +75,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_reauth(self, _): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform the re-auth step upon an API authentication error.""" return await self.async_step_user() diff --git a/homeassistant/components/picnic/translations/bg.json b/homeassistant/components/picnic/translations/bg.json index c0ccf23f5b5..32ea4287182 100644 --- a/homeassistant/components/picnic/translations/bg.json +++ b/homeassistant/components/picnic/translations/bg.json @@ -2,6 +2,16 @@ "config": { "abort": { "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "country_code": "\u041a\u043e\u0434 \u043d\u0430 \u0434\u044a\u0440\u0436\u0430\u0432\u0430\u0442\u0430" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/sv.json b/homeassistant/components/picnic/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/picnic/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index dc9533c4be2..637122b1d9c 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Plaato.""" +from __future__ import annotations + from pyplaato.plaato import PlaatoDeviceType import voluptuous as vol @@ -161,7 +163,7 @@ class PlaatoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> PlaatoOptionsFlowHandler: """Get the options flow for this handler.""" return PlaatoOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index dbfe55077d7..c8745213f90 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -14,7 +14,7 @@ from plexwebsocket import ( import requests.exceptions from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, BrowseError -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -139,12 +139,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, PLEX_SERVER_CONFIG: new_server_data} ) except requests.exceptions.ConnectionError as error: - if entry.state is not ConfigEntryState.SETUP_RETRY: - _LOGGER.error( - "Plex server (%s) could not be reached: [%s]", - server_config[CONF_URL], - error, - ) raise ConfigEntryNotReady from error except plexapi.exceptions.Unauthorized as ex: raise ConfigEntryAuthFailed( diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 8da67dafc3d..42d227154a6 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -1,6 +1,10 @@ """Config flow for Plex.""" +from __future__ import annotations + +from collections.abc import Mapping import copy import logging +from typing import Any from aiohttp import web_response import plexapi.exceptions @@ -24,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -85,7 +90,9 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> PlexOptionsFlowHandler: """Get the options flow for this handler.""" return PlexOptionsFlowHandler(config_entry) @@ -325,16 +332,16 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): server_config = {CONF_TOKEN: self.token} return await self.async_step_server_validate(server_config) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" - self.current_login = dict(data) + self.current_login = dict(entry_data) return await self.async_step_user() class PlexOptionsFlowHandler(config_entries.OptionsFlow): """Handle Plex options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Plex options flow.""" self.options = copy.deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index a350543ee07..42dcee96196 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -1,24 +1,7 @@ { - "options": { - "step": { - "init": { - "description": "Adjust Plugwise Options", - "data": { - "scan_interval": "Scan Interval (seconds)" - } - } - } - }, "config": { "step": { "user": { - "title": "Plugwise type", - "description": "Product:", - "data": { - "flow_type": "Connection type" - } - }, - "user_gateway": { "title": "Connect to the Smile", "description": "Please enter", "data": { @@ -37,7 +20,6 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - }, - "flow_title": "{name}" + } } } diff --git a/homeassistant/components/plugwise/translations/bg.json b/homeassistant/components/plugwise/translations/bg.json index 0c1cf067319..18450edfce7 100644 --- a/homeassistant/components/plugwise/translations/bg.json +++ b/homeassistant/components/plugwise/translations/bg.json @@ -11,6 +11,10 @@ "flow_title": "{name}", "step": { "user": { + "data": { + "host": "IP \u0430\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + }, "description": "\u041f\u0440\u043e\u0434\u0443\u043a\u0442:" }, "user_gateway": { diff --git a/homeassistant/components/plugwise/translations/ca.json b/homeassistant/components/plugwise/translations/ca.json index 85f7c357803..1bf9efee843 100644 --- a/homeassistant/components/plugwise/translations/ca.json +++ b/homeassistant/components/plugwise/translations/ca.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Tipus de connexi\u00f3" + "flow_type": "Tipus de connexi\u00f3", + "host": "Adre\u00e7a IP", + "password": "ID de Smile", + "port": "Port", + "username": "Usuari de Smile" }, - "description": "Producte:", - "title": "Tipus de Plugwise" + "description": "Introdueix", + "title": "Connexi\u00f3 amb Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/de.json b/homeassistant/components/plugwise/translations/de.json index f6ce5fb1b2d..2b9d112977a 100644 --- a/homeassistant/components/plugwise/translations/de.json +++ b/homeassistant/components/plugwise/translations/de.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Verbindungstyp" + "flow_type": "Verbindungstyp", + "host": "IP-Adresse", + "password": "Smile ID", + "port": "Port", + "username": "Smile-Benutzername" }, - "description": "Produkt:", - "title": "Plugwise Typ" + "description": "Bitte eingeben", + "title": "Stelle eine Verbindung zu Smile her" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/el.json b/homeassistant/components/plugwise/translations/el.json index 8407cb38cb1..caee7bb5a88 100644 --- a/homeassistant/components/plugwise/translations/el.json +++ b/homeassistant/components/plugwise/translations/el.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" + "flow_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "host": "\u0394\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IP", + "password": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc Smile", + "port": "\u0398\u03cd\u03c1\u03b1", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7 Smile" }, "description": "\u03a0\u03c1\u03bf\u03ca\u03cc\u03bd:", "title": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03b2\u03cd\u03c3\u03bc\u03b1\u03c4\u03bf\u03c2" diff --git a/homeassistant/components/plugwise/translations/en.json b/homeassistant/components/plugwise/translations/en.json index 616e450cb11..3f365bfa25e 100644 --- a/homeassistant/components/plugwise/translations/en.json +++ b/homeassistant/components/plugwise/translations/en.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Connection type" + "flow_type": "Connection type", + "host": "IP Address", + "password": "Smile ID", + "port": "Port", + "username": "Smile Username" }, - "description": "Product:", - "title": "Plugwise type" + "description": "Please enter", + "title": "Connect to the Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/es.json b/homeassistant/components/plugwise/translations/es.json index e1d1bf3359b..16fae70586d 100644 --- a/homeassistant/components/plugwise/translations/es.json +++ b/homeassistant/components/plugwise/translations/es.json @@ -5,14 +5,19 @@ }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida, comprueba los 8 caracteres de tu Smile ID", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_setup": "Agregue su Adam en lugar de su Anna, consulte la documentaci\u00f3n de integraci\u00f3n de Home Assistant Plugwise para obtener m\u00e1s informaci\u00f3n", "unknown": "Error inesperado" }, "flow_title": "{name}", "step": { "user": { "data": { - "flow_type": "Tipo de conexi\u00f3n" + "flow_type": "Tipo de conexi\u00f3n", + "host": "Direcci\u00f3n IP", + "password": "ID Smile", + "port": "Puerto", + "username": "Nombre de usuario de la sonrisa" }, "description": "Producto:", "title": "Conectarse a Smile" diff --git a/homeassistant/components/plugwise/translations/et.json b/homeassistant/components/plugwise/translations/et.json index f863dd30b19..2d50be06193 100644 --- a/homeassistant/components/plugwise/translations/et.json +++ b/homeassistant/components/plugwise/translations/et.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "\u00dchenduse t\u00fc\u00fcp" + "flow_type": "\u00dchenduse t\u00fc\u00fcp", + "host": "IP aadress", + "password": "Smile ID", + "port": "Port", + "username": "Smile kasutajanimi" }, - "description": "Toode:", - "title": "Plugwise t\u00fc\u00fcp" + "description": "Sisesta andmed", + "title": "Loo \u00fchendus Smile-ga" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/fr.json b/homeassistant/components/plugwise/translations/fr.json index baa6074e68a..0306437b405 100644 --- a/homeassistant/components/plugwise/translations/fr.json +++ b/homeassistant/components/plugwise/translations/fr.json @@ -13,9 +13,13 @@ "step": { "user": { "data": { - "flow_type": "Type de connexion" + "flow_type": "Type de connexion", + "host": "Adresse IP", + "password": "ID Smile", + "port": "Port", + "username": "Nom d'utilisateur Smile" }, - "description": "Veuillez saisir :", + "description": "Veuillez saisir", "title": "Se connecter \u00e0 Smile" }, "user_gateway": { @@ -23,7 +27,7 @@ "host": "Adresse IP", "password": "ID Smile", "port": "Port", - "username": "Nom d'utilisateur de sourire" + "username": "Nom d'utilisateur Smile" }, "description": "Veuillez saisir :", "title": "Se connecter \u00e0 Smile" diff --git a/homeassistant/components/plugwise/translations/hu.json b/homeassistant/components/plugwise/translations/hu.json index 265a99186ba..b622109797c 100644 --- a/homeassistant/components/plugwise/translations/hu.json +++ b/homeassistant/components/plugwise/translations/hu.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Kapcsolat t\u00edpusa" + "flow_type": "Kapcsolat t\u00edpusa", + "host": "IP c\u00edm", + "password": "Smile azonos\u00edt\u00f3", + "port": "Port", + "username": "Smile Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Term\u00e9k:", - "title": "Plugwise t\u00edpus" + "description": "K\u00e9rem, adja meg", + "title": "Csatlakoz\u00e1s a Smile-hoz" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/id.json b/homeassistant/components/plugwise/translations/id.json index 8878a4e775d..22c871e4f83 100644 --- a/homeassistant/components/plugwise/translations/id.json +++ b/homeassistant/components/plugwise/translations/id.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Jenis koneksi" + "flow_type": "Jenis koneksi", + "host": "Alamat IP", + "password": "ID Smile", + "port": "Port", + "username": "Nama Pengguna Smile" }, - "description": "Produk:", - "title": "Jenis Plugwise" + "description": "Masukkan", + "title": "Hubungkan ke Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/it.json b/homeassistant/components/plugwise/translations/it.json index 9e051a29e13..24e75f3e846 100644 --- a/homeassistant/components/plugwise/translations/it.json +++ b/homeassistant/components/plugwise/translations/it.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Tipo di connessione" + "flow_type": "Tipo di connessione", + "host": "Indirizzo IP", + "password": "ID Smile", + "port": "Porta", + "username": "Nome utente Smile" }, - "description": "Prodotto:", - "title": "Tipo di Plugwise" + "description": "Inserisci", + "title": "Connettiti allo Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/ja.json b/homeassistant/components/plugwise/translations/ja.json index f15d62d38d1..87b8d501e0d 100644 --- a/homeassistant/components/plugwise/translations/ja.json +++ b/homeassistant/components/plugwise/translations/ja.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "\u63a5\u7d9a\u30bf\u30a4\u30d7" + "flow_type": "\u63a5\u7d9a\u30bf\u30a4\u30d7", + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "Smile\u306eID", + "port": "\u30dd\u30fc\u30c8", + "username": "Smile\u306e\u30e6\u30fc\u30b6\u30fc\u540d" }, "description": "\u30d7\u30ed\u30c0\u30af\u30c8:", "title": "Plugwise type" diff --git a/homeassistant/components/plugwise/translations/nl.json b/homeassistant/components/plugwise/translations/nl.json index 8109386013c..14d25d6716e 100644 --- a/homeassistant/components/plugwise/translations/nl.json +++ b/homeassistant/components/plugwise/translations/nl.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "Verbindingstype" + "flow_type": "Verbindingstype", + "host": "IP-adres", + "password": "Smile-ID", + "port": "Poort", + "username": "Smile-gebruikersnaam" }, "description": "Product:", "title": "Plugwise type" diff --git a/homeassistant/components/plugwise/translations/no.json b/homeassistant/components/plugwise/translations/no.json index d8ce2d8956a..d9fbb15b2e8 100644 --- a/homeassistant/components/plugwise/translations/no.json +++ b/homeassistant/components/plugwise/translations/no.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Tilkoblingstype" + "flow_type": "Tilkoblingstype", + "host": "IP adresse", + "password": "Smile ID", + "port": "Port", + "username": "Smile brukernavn" }, - "description": "Produkt:", - "title": "" + "description": "Vennligst skriv inn", + "title": "Koble til Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/pl.json b/homeassistant/components/plugwise/translations/pl.json index c4a2efe95c3..3d6a3a3b354 100644 --- a/homeassistant/components/plugwise/translations/pl.json +++ b/homeassistant/components/plugwise/translations/pl.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Typ po\u0142\u0105czenia" + "flow_type": "Typ po\u0142\u0105czenia", + "host": "Adres IP", + "password": "Identyfikator Smile", + "port": "Port", + "username": "Nazwa u\u017cytkownika Smile" }, - "description": "Wybierz produkt:", - "title": "Wybierz typ Plugwise" + "description": "Wprowad\u017a:", + "title": "Po\u0142\u0105czenie ze Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/pt-BR.json b/homeassistant/components/plugwise/translations/pt-BR.json index 35a08d93114..12f8070f074 100644 --- a/homeassistant/components/plugwise/translations/pt-BR.json +++ b/homeassistant/components/plugwise/translations/pt-BR.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Tipo de conex\u00e3o" + "flow_type": "Tipo de conex\u00e3o", + "host": "Endere\u00e7o IP", + "password": "ID do Smile", + "port": "Porta", + "username": "Nome de usu\u00e1rio do Smile" }, - "description": "Produto:", - "title": "Tipo Plugwise" + "description": "Por favor, insira", + "title": "Conecte-se ao Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/sv.json b/homeassistant/components/plugwise/translations/sv.json index acbb205a11f..affb73f907e 100644 --- a/homeassistant/components/plugwise/translations/sv.json +++ b/homeassistant/components/plugwise/translations/sv.json @@ -1,6 +1,11 @@ { "config": { "step": { + "user": { + "data": { + "port": "Port" + } + }, "user_gateway": { "data": { "host": "IP address", diff --git a/homeassistant/components/plugwise/translations/tr.json b/homeassistant/components/plugwise/translations/tr.json index ae756bf15d4..b4b49a5e52d 100644 --- a/homeassistant/components/plugwise/translations/tr.json +++ b/homeassistant/components/plugwise/translations/tr.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "Ba\u011flant\u0131 t\u00fcr\u00fc" + "flow_type": "Ba\u011flant\u0131 t\u00fcr\u00fc", + "host": "IP Adresi", + "password": "Smile Kimli\u011fi", + "port": "Port", + "username": "Smile Kullan\u0131c\u0131 Ad\u0131" }, "description": "\u00dcr\u00fcn:", "title": "Plugwise tipi" diff --git a/homeassistant/components/plugwise/translations/zh-Hant.json b/homeassistant/components/plugwise/translations/zh-Hant.json index 57a7add22e0..9d37a79462b 100644 --- a/homeassistant/components/plugwise/translations/zh-Hant.json +++ b/homeassistant/components/plugwise/translations/zh-Hant.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "\u9023\u7dda\u985e\u5225" + "flow_type": "\u9023\u7dda\u985e\u5225", + "host": "IP \u4f4d\u5740", + "password": "Smile ID", + "port": "\u901a\u8a0a\u57e0", + "username": "Smile \u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u7522\u54c1\uff1a", - "title": "Plugwise \u985e\u5225" + "description": "\u8acb\u8f38\u5165", + "title": "\u9023\u7dda\u81f3 Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plum_lightpad/translations/sv.json b/homeassistant/components/plum_lightpad/translations/sv.json new file mode 100644 index 00000000000..26e9f2d6a49 --- /dev/null +++ b/homeassistant/components/plum_lightpad/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pocketcasts/manifest.json b/homeassistant/components/pocketcasts/manifest.json index f74c77ed3a9..8ee74496447 100644 --- a/homeassistant/components/pocketcasts/manifest.json +++ b/homeassistant/components/pocketcasts/manifest.json @@ -2,7 +2,7 @@ "domain": "pocketcasts", "name": "Pocket Casts", "documentation": "https://www.home-assistant.io/integrations/pocketcasts", - "requirements": ["pycketcasts==1.0.0"], + "requirements": ["pycketcasts==1.0.1"], "codeowners": [], "iot_class": "cloud_polling", "loggers": ["pycketcasts"] diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 46ea95ba927..bfffb934407 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -1,4 +1,7 @@ """Support for Minut Point.""" +from __future__ import annotations + +from collections.abc import Callable import logging from homeassistant.components.alarm_control_panel import ( @@ -17,6 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MinutPointClient from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -51,21 +55,29 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY - def __init__(self, point_client, home_id): + def __init__(self, point_client: MinutPointClient, home_id: str) -> None: """Initialize the entity.""" self._client = point_client self._home_id = home_id - self._async_unsub_hook_dispatcher_connect = None - self._changed_by = None + self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None + self._home = point_client.homes[self._home_id] - async def async_added_to_hass(self): + self._attr_name = self._home["name"] + self._attr_unique_id = f"point.{home_id}" + self._attr_device_info = DeviceInfo( + identifiers={(POINT_DOMAIN, home_id)}, + manufacturer="Minut", + name=self._attr_name, + ) + + async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( self.hass, SIGNAL_WEBHOOK, self._webhook_event ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" await super().async_will_remove_from_hass() if self._async_unsub_hook_dispatcher_connect: @@ -83,51 +95,22 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): return _LOGGER.debug("Received webhook: %s", _type) self._home["alarm_status"] = _type - self._changed_by = _changed_by + self._attr_changed_by = _changed_by self.async_write_ha_state() @property - def _home(self): - """Return the home object.""" - return self._client.homes[self._home_id] - - @property - def name(self): - """Return name of the device.""" - return self._home["name"] - - @property - def state(self): + def state(self) -> str: """Return state of the device.""" return EVENT_MAP.get(self._home["alarm_status"], STATE_ALARM_ARMED_AWAY) - @property - def changed_by(self): - """Return the user the last change was triggered by.""" - return self._changed_by - - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" status = await self._client.async_alarm_disarm(self._home_id) if status: self._home["alarm_status"] = "off" - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" status = await self._client.async_alarm_arm(self._home_id) if status: self._home["alarm_status"] = "on" - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._home_id}" - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - identifiers={(POINT_DOMAIN, self._home_id)}, - manufacturer="Minut", - name=self.name, - ) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 836aa46e2a4..b9f6f3969fd 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Tesla Powerwall integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -205,7 +206,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index be5d4678e27..3d1eb07f3fa 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla Powerwall", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerwall", - "requirements": ["tesla-powerwall==0.3.17"], + "requirements": ["tesla-powerwall==0.3.18"], "codeowners": ["@bdraco", "@jrester"], "dhcp": [ { diff --git a/homeassistant/components/powerwall/translations/es.json b/homeassistant/components/powerwall/translations/es.json index c0f73c2ac99..ae578b37e50 100644 --- a/homeassistant/components/powerwall/translations/es.json +++ b/homeassistant/components/powerwall/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "El powerwall ya est\u00e1 configurado", + "cannot_connect": "Fallo en la conexi\u00f3n", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { @@ -13,12 +14,14 @@ "flow_title": "Powerwall de Tesla ({ip_address})", "step": { "confirm_discovery": { - "description": "\u00bfQuieres configurar {name} ({ip_address})?" + "description": "\u00bfQuieres configurar {name} ({ip_address})?", + "title": "Conectar al powerwall" }, "reauth_confim": { "data": { "password": "Contrase\u00f1a" }, + "description": "La contrase\u00f1a suele ser los \u00faltimos 5 caracteres del n\u00famero de serie de Backup Gateway y se puede encontrar en la aplicaci\u00f3n de Tesla o los \u00faltimos 5 caracteres de la contrase\u00f1a que se encuentra dentro de la puerta de Backup Gateway 2.", "title": "Reautorizar la powerwall" }, "user": { diff --git a/homeassistant/components/powerwall/translations/sv.json b/homeassistant/components/powerwall/translations/sv.json index 0c6f94cd697..01b1eccd5c0 100644 --- a/homeassistant/components/powerwall/translations/sv.json +++ b/homeassistant/components/powerwall/translations/sv.json @@ -1,14 +1,19 @@ { "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, "error": { - "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "cannot_connect": "Det gick inte att ansluta", + "invalid_auth": "Felaktig autentisering", "unknown": "Ov\u00e4ntat fel", "wrong_version": "Powerwall anv\u00e4nder en programvaruversion som inte st\u00f6ds. T\u00e4nk p\u00e5 att uppgradera eller rapportera det h\u00e4r problemet s\u00e5 att det kan l\u00f6sas." }, "step": { "user": { "data": { - "ip_address": "IP-adress" + "ip_address": "IP-adress", + "password": "L\u00f6senord" } } } diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index e7176b241e5..133c182e2cc 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Prosegur alarm control panels.""" +from __future__ import annotations + import logging from pyprosegur.auth import Auth @@ -44,12 +46,12 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_HOME ) + _installation: Installation def __init__(self, contract: str, auth: Auth) -> None: """Initialize the Prosegur alarm panel.""" self._changed_by = None - self._installation = None self.contract = contract self._auth = auth @@ -57,7 +59,7 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._attr_name = f"contract {self.contract}" self._attr_unique_id = self.contract - async def async_update(self): + async def async_update(self) -> None: """Update alarm status.""" try: @@ -70,14 +72,14 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._attr_state = STATE_MAPPING.get(self._installation.status) self._attr_available = True - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._installation.disarm(self._auth) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm away command.""" await self._installation.arm_partially(self._auth) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._installation.arm(self._auth) diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index 1807561663b..ee2fa795f2d 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Prosegur Alarm integration.""" +from collections.abc import Mapping import logging +from typing import Any, cast from pyprosegur.auth import COUNTRY, Auth from pyprosegur.installation import Installation @@ -8,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_COUNTRY, DOMAIN @@ -75,9 +78,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Prosegur.""" - self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + self.entry = cast( + ConfigEntry, + 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=None): diff --git a/homeassistant/components/prosegur/translations/sv.json b/homeassistant/components/prosegur/translations/sv.json new file mode 100644 index 00000000000..4ae0ae62971 --- /dev/null +++ b/homeassistant/components/prosegur/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index e52b91d4cba..97995431778 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -1,7 +1,10 @@ """Binary sensor to read Proxmox VE data.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -32,31 +35,18 @@ async def async_setup_platform( for node_config in host_config["nodes"]: node_name = node_config["node"] - for vm_id in node_config["vms"]: - coordinator = host_name_coordinators[node_name][vm_id] + for dev_id in node_config["vms"] + node_config["containers"]: + coordinator = host_name_coordinators[node_name][dev_id] - # unfound vm case + # unfound case if (coordinator_data := coordinator.data) is None: continue - vm_name = coordinator_data["name"] - vm_sensor = create_binary_sensor( - coordinator, host_name, node_name, vm_id, vm_name + name = coordinator_data["name"] + sensor = create_binary_sensor( + coordinator, host_name, node_name, dev_id, name ) - sensors.append(vm_sensor) - - for container_id in node_config["containers"]: - coordinator = host_name_coordinators[node_name][container_id] - - # unfound container case - if (coordinator_data := coordinator.data) is None: - continue - - container_name = coordinator_data["name"] - container_sensor = create_binary_sensor( - coordinator, host_name, node_name, container_id, container_name - ) - sensors.append(container_sensor) + sensors.append(sensor) add_entities(sensors) @@ -66,7 +56,7 @@ def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): return ProxmoxBinarySensor( coordinator=coordinator, unique_id=f"proxmox_{node_name}_{vm_id}_running", - name=f"{node_name}_{name}_running", + name=f"{node_name}_{name}", icon="", host_name=host_name, node_name=node_name, @@ -77,6 +67,8 @@ def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): """A binary sensor for reading Proxmox VE data.""" + _attr_device_class = BinarySensorDeviceClass.RUNNING + def __init__( self, coordinator: DataUpdateCoordinator, diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 4b600abc930..aa76aa60118 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -3,7 +3,7 @@ "name": "Proxmox VE", "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "codeowners": ["@jhollowe", "@Corbeno"], - "requirements": ["proxmoxer==1.1.1"], + "requirements": ["proxmoxer==1.3.1"], "iot_class": "local_polling", "loggers": ["proxmoxer"] } diff --git a/homeassistant/components/ps4/translations/es.json b/homeassistant/components/ps4/translations/es.json index a2c6e6fd1f4..4eb7636d0a2 100644 --- a/homeassistant/components/ps4/translations/es.json +++ b/homeassistant/components/ps4/translations/es.json @@ -23,12 +23,18 @@ "ip_address": "Direcci\u00f3n IP", "name": "Nombre", "region": "Regi\u00f3n" + }, + "data_description": { + "code": "Vaya a 'Configuraci\u00f3n' en su consola PlayStation 4. Luego navegue hasta 'Configuraci\u00f3n de conexi\u00f3n de la aplicaci\u00f3n m\u00f3vil' y seleccione 'Agregar dispositivo' para obtener el pin." } }, "mode": { "data": { "ip_address": "Direcci\u00f3n IP (d\u00e9jalo en blanco si usas la detecci\u00f3n autom\u00e1tica).", "mode": "Modo configuraci\u00f3n" + }, + "data_description": { + "ip_address": "D\u00e9jelo en blanco si selecciona la detecci\u00f3n autom\u00e1tica." } } } diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py index 4349a79593e..2016f87e611 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the PVOutput integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pvo import PVOutput, PVOutputAuthenticationError, PVOutputError @@ -83,7 +84,7 @@ class PVOutputFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with PVOutput.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/pvoutput/translations/sv.json b/homeassistant/components/pvoutput/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/pvoutput/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 76694d570b5..f5aeb951d33 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -1,4 +1,6 @@ """Config flow for pvpc_hourly_pricing.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -15,7 +17,9 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> PVPCOptionsFlowHandler: """Get the options flow for this handler.""" return PVPCOptionsFlowHandler(config_entry) @@ -36,7 +40,7 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class PVPCOptionsFlowHandler(config_entries.OptionsFlow): """Handle PVPC options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 1e2bf5b6892..7366dc5dc41 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -306,7 +306,7 @@ class QNAPStatsAPI: self.data["smart_drive_health"] = self._api.get_smart_disk_health() self.data["volumes"] = self._api.get_volumes() self.data["bandwidth"] = self._api.get_bandwidth() - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to fetch QNAP stats from the NAS") diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 891c72c9911..bb42c9ea294 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -1,6 +1,7 @@ """Config flow for QNAP QSW.""" from __future__ import annotations +import logging from typing import Any from aioqsw.exceptions import LoginError, QswError @@ -8,6 +9,7 @@ from aioqsw.localapi import ConnectionOptions, QnapQswApi import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import aiohttp_client @@ -15,10 +17,15 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle config flow for a QNAP QSW device.""" + _discovered_mac: str | None = None + _discovered_url: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -46,7 +53,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if mac is None: raise AbortFlow("invalid_id") - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id(format_mac(mac), raise_on_progress=False) self._abort_if_unique_id_configured() title = f"QNAP {system_board.get_product()} {mac}" @@ -63,3 +70,62 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle DHCP discovery.""" + self._discovered_url = f"http://{discovery_info.ip}" + self._discovered_mac = discovery_info.macaddress + + _LOGGER.debug("DHCP discovery detected QSW: %s", self._discovered_mac) + + mac = format_mac(self._discovered_mac) + options = ConnectionOptions(self._discovered_url, "", "") + qsw = QnapQswApi(aiohttp_client.async_get_clientsession(self.hass), options) + + try: + await qsw.get_live() + except QswError as err: + raise AbortFlow("cannot_connect") from err + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured() + + return await self.async_step_discovered_connection() + + async def async_step_discovered_connection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + errors = {} + assert self._discovered_url is not None + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + qsw = QnapQswApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions(self._discovered_url, username, password), + ) + + try: + system_board = await qsw.validate() + except LoginError: + errors[CONF_PASSWORD] = "invalid_auth" + except QswError: + errors["base"] = "cannot_connect" + else: + title = f"QNAP {system_board.get_product()} {self._discovered_mac}" + user_input[CONF_URL] = self._discovered_url + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="discovered_connection", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index 9331a7df468..be565f2a07e 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -3,8 +3,13 @@ "name": "QNAP QSW", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", - "requirements": ["aioqsw==0.0.8"], + "requirements": ["aioqsw==0.1.0"], "codeowners": ["@Noltari"], "iot_class": "local_polling", - "loggers": ["aioqsw"] + "loggers": ["aioqsw"], + "dhcp": [ + { + "macaddress": "245EBE*" + } + ] } diff --git a/homeassistant/components/qnap_qsw/strings.json b/homeassistant/components/qnap_qsw/strings.json index 351245a9591..ba0cb28ba77 100644 --- a/homeassistant/components/qnap_qsw/strings.json +++ b/homeassistant/components/qnap_qsw/strings.json @@ -9,6 +9,12 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { + "discovered_connection": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]", diff --git a/homeassistant/components/qnap_qsw/translations/en.json b/homeassistant/components/qnap_qsw/translations/en.json index b6f68f2f062..c75c2d76ac8 100644 --- a/homeassistant/components/qnap_qsw/translations/en.json +++ b/homeassistant/components/qnap_qsw/translations/en.json @@ -9,6 +9,12 @@ "invalid_auth": "Invalid authentication" }, "step": { + "discovered_connection": { + "data": { + "password": "Password", + "username": "Username" + } + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/qnap_qsw/translations/sv.json b/homeassistant/components/qnap_qsw/translations/sv.json index 416ef964cf3..ec6c8842dca 100644 --- a/homeassistant/components/qnap_qsw/translations/sv.json +++ b/homeassistant/components/qnap_qsw/translations/sv.json @@ -3,6 +3,16 @@ "abort": { "already_configured": "Enheten \u00e4r redan konfigurerad", "invalid_id": "Enheten returnerade ett ogiltigt unikt ID" + }, + "error": { + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index e75d7117d73..e0ac98b7546 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -17,6 +17,7 @@ from .device import RachioPerson from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, async_register_webhook, + async_unregister_webhook, ) _LOGGER = logging.getLogger(__name__) @@ -28,8 +29,8 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) 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) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + async_unregister_webhook(hass, entry) hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -59,10 +60,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Get the URL of this server rachio.webhook_auth = secrets.token_hex() try: - ( - webhook_id, - webhook_url, - ) = await async_get_or_create_registered_webhook_id_and_url(hass, entry) + webhook_url = await async_get_or_create_registered_webhook_id_and_url( + hass, entry + ) except cloud.CloudNotConnected as exc: # User has an active cloud subscription, but the connection to the cloud is down raise ConfigEntryNotReady from exc @@ -92,9 +92,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Enable platform - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = person - async_register_webhook(hass, webhook_id, entry.entry_id) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person + async_register_webhook(hass, entry) hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 1bfc3cc03ee..2fe99fb442e 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -21,6 +22,7 @@ from .const import ( SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, STATUS_ONLINE, ) +from .device import RachioPerson from .entity import RachioDevice from .webhooks import ( SUBTYPE_COLD_REBOOT, @@ -41,12 +43,13 @@ async def async_setup_entry( """Set up the Rachio binary sensors.""" entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) async_add_entities(entities) - _LOGGER.info("%d Rachio binary sensor(s) added", len(entities)) + _LOGGER.debug("%d Rachio binary sensor(s) added", len(entities)) -def _create_entities(hass, config_entry): - entities = [] - for controller in hass.data[DOMAIN_RACHIO][config_entry.entry_id].controllers: +def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: + entities: list[Entity] = [] + person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + for controller in person.controllers: entities.append(RachioControllerOnlineBinarySensor(controller)) entities.append(RachioRainSensor(controller)) return entities diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index 9e93dba065e..00f31003ba6 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Rachio integration.""" +from __future__ import annotations + from http import HTTPStatus import logging @@ -92,7 +94,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 9dbf14e3907..92a57505a7c 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -67,3 +67,13 @@ SIGNAL_RACHIO_SCHEDULE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_schedule" CONF_WEBHOOK_ID = "webhook_id" CONF_CLOUDHOOK_URL = "cloudhook_url" + +# Webhook callbacks +LISTEN_EVENT_TYPES = [ + "DEVICE_STATUS_EVENT", + "ZONE_STATUS_EVENT", + "RAIN_DELAY_EVENT", + "RAIN_SENSOR_DETECTION_EVENT", + "SCHEDULE_STATUS_EVENT", +] +WEBHOOK_CONST_ID = "homeassistant.rachio:" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 911049883d9..5053fa01495 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -3,11 +3,14 @@ from __future__ import annotations from http import HTTPStatus import logging +from typing import Any +from rachiopy import Rachio import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -26,12 +29,13 @@ from .const import ( KEY_STATUS, KEY_USERNAME, KEY_ZONES, + LISTEN_EVENT_TYPES, MODEL_GENERATION_1, SERVICE_PAUSE_WATERING, SERVICE_RESUME_WATERING, SERVICE_STOP_WATERING, + WEBHOOK_CONST_ID, ) -from .webhooks import LISTEN_EVENT_TYPES, WEBHOOK_CONST_ID _LOGGER = logging.getLogger(__name__) @@ -54,16 +58,16 @@ STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) class RachioPerson: """Represent a Rachio user.""" - def __init__(self, rachio, config_entry): + def __init__(self, rachio: Rachio, config_entry: ConfigEntry) -> None: """Create an object from the provided API instance.""" # Use API token to get user ID self.rachio = rachio self.config_entry = config_entry self.username = None - self._id = None - self._controllers = [] + self._id: str | None = None + self._controllers: list[RachioIro] = [] - async def async_setup(self, hass): + async def async_setup(self, hass: HomeAssistant) -> None: """Create rachio devices and services.""" await hass.async_add_executor_job(self._setup, hass) can_pause = False @@ -121,7 +125,7 @@ class RachioPerson: schema=RESUME_SERVICE_SCHEMA, ) - def _setup(self, hass): + def _setup(self, hass: HomeAssistant) -> None: """Rachio device setup.""" rachio = self.rachio @@ -139,7 +143,7 @@ class RachioPerson: if int(data[0][KEY_STATUS]) != HTTPStatus.OK: raise ConfigEntryNotReady(f"API Error: {data}") self.username = data[1][KEY_USERNAME] - devices = data[1][KEY_DEVICES] + devices: list[dict[str, Any]] = data[1][KEY_DEVICES] for controller in devices: webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1] # The API does not provide a way to tell if a controller is shared @@ -169,12 +173,12 @@ class RachioPerson: _LOGGER.info('Using Rachio API as user "%s"', self.username) @property - def user_id(self) -> str: + def user_id(self) -> str | None: """Get the user ID as defined by the Rachio API.""" return self._id @property - def controllers(self) -> list: + def controllers(self) -> list[RachioIro]: """Get a list of controllers managed by this account.""" return self._controllers @@ -186,7 +190,13 @@ class RachioPerson: class RachioIro: """Represent a Rachio Iro.""" - def __init__(self, hass, rachio, data, webhooks): + def __init__( + self, + hass: HomeAssistant, + rachio: Rachio, + data: dict[str, Any], + webhooks: list[dict[str, Any]], + ) -> None: """Initialize a Rachio device.""" self.hass = hass self.rachio = rachio @@ -199,10 +209,10 @@ class RachioIro: self._schedules = data[KEY_SCHEDULES] self._flex_schedules = data[KEY_FLEX_SCHEDULES] self._init_data = data - self._webhooks = webhooks + self._webhooks: list[dict[str, Any]] = webhooks _LOGGER.debug('%s has ID "%s"', self, self.controller_id) - def setup(self): + def setup(self) -> None: """Rachio Iro setup for webhooks.""" # Listen for all updates self._init_webhooks() @@ -226,7 +236,7 @@ class RachioIro: or webhook[KEY_ID] == current_webhook_id ): self.rachio.notification.delete(webhook[KEY_ID]) - self._webhooks = None + self._webhooks = [] _deinit_webhooks(None) @@ -306,9 +316,6 @@ class RachioIro: _LOGGER.debug("Resuming watering on %s", self) -def is_invalid_auth_code(http_status_code): +def is_invalid_auth_code(http_status_code: int) -> bool: """HTTP status codes that mean invalid auth.""" - if http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - return True - - return False + return http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN) diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index a95b6ffb557..1bb971e3e01 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -4,25 +4,19 @@ from homeassistant.helpers import device_registry from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DEFAULT_NAME, DOMAIN +from .device import RachioIro class RachioDevice(Entity): """Base class for rachio devices.""" - def __init__(self, controller): + _attr_should_poll = False + + def __init__(self, controller: RachioIro) -> None: """Initialize a Rachio device.""" super().__init__() self._controller = controller - - @property - def should_poll(self) -> bool: - """Declare that this entity pushes its state to HA.""" - return False - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ ( DOMAIN, diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 55427741bf0..227e8beaec3 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp @@ -52,6 +53,7 @@ from .const import ( SLOPE_SLIGHT, SLOPE_STEEP, ) +from .device import RachioPerson from .entity import RachioDevice from .webhooks import ( SUBTYPE_RAIN_DELAY_OFF, @@ -106,7 +108,7 @@ async def async_setup_entry( has_flex_sched = True async_add_entities(entities) - _LOGGER.info("%d Rachio switch(es) added", len(entities)) + _LOGGER.debug("%d Rachio switch(es) added", len(entities)) def start_multiple(service: ServiceCall) -> None: """Service to start multiple zones in sequence.""" @@ -154,9 +156,9 @@ async def async_setup_entry( ) -def _create_entities(hass, config_entry): - entities = [] - person = hass.data[DOMAIN_RACHIO][config_entry.entry_id] +def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: + entities: list[Entity] = [] + person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] # Fetch the schedule once at startup # in order to avoid every zone doing it for controller in person.controllers: diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 6ad396b76a1..5c2fbe5965f 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -1,9 +1,12 @@ """Webhooks used by rachio.""" +from __future__ import annotations + from aiohttp import web from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigEntry from homeassistant.const import URL_API -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -18,6 +21,7 @@ from .const import ( SIGNAL_RACHIO_SCHEDULE_UPDATE, SIGNAL_RACHIO_ZONE_UPDATE, ) +from .device import RachioPerson # Device webhook values TYPE_CONTROLLER_STATUS = "DEVICE_STATUS" @@ -79,16 +83,22 @@ SIGNAL_MAP = { @callback -def async_register_webhook(hass, webhook_id, entry_id): +def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: """Register a webhook.""" + webhook_id: str = entry.data[CONF_WEBHOOK_ID] - async def _async_handle_rachio_webhook(hass, webhook_id, request): + async def _async_handle_rachio_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: """Handle webhook calls from the server.""" + person: RachioPerson = hass.data[DOMAIN][entry.entry_id] data = await request.json() try: - auth = data.get(KEY_EXTERNAL_ID, "").split(":")[1] - assert auth == hass.data[DOMAIN][entry_id].rachio.webhook_auth + assert ( + data.get(KEY_EXTERNAL_ID, "").split(":")[1] + == person.rachio.webhook_auth + ) except (AssertionError, IndexError): return web.Response(status=web.HTTPForbidden.status_code) @@ -103,8 +113,17 @@ def async_register_webhook(hass, webhook_id, entry_id): ) -async def async_get_or_create_registered_webhook_id_and_url(hass, entry): - """Generate webhook ID.""" +@callback +def async_unregister_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Unregister a webhook.""" + webhook_id: str = entry.data[CONF_WEBHOOK_ID] + webhook.async_unregister(hass, webhook_id) + + +async def async_get_or_create_registered_webhook_id_and_url( + hass: HomeAssistant, entry: ConfigEntry +) -> str: + """Generate webhook url.""" config = entry.data.copy() updated_config = False @@ -128,4 +147,4 @@ async def async_get_or_create_registered_webhook_id_and_url(hass, entry): if updated_config: hass.config_entries.async_update_entry(entry, data=config) - return webhook_id, webhook_url + return webhook_url diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index adc8cdbd6ee..865e75257ec 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1 +1,76 @@ """The radiotherm component.""" +from __future__ import annotations + +from collections.abc import Coroutine +from socket import timeout +from typing import Any, TypeVar +from urllib.error import URLError + +from radiotherm.validate import RadiothermTstatError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import RadioThermUpdateCoordinator +from .data import async_get_init_data +from .util import async_set_time + +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] + +_T = TypeVar("_T") + + +async def _async_call_or_raise_not_ready( + coro: Coroutine[Any, Any, _T], host: str +) -> _T: + """Call a coro or raise ConfigEntryNotReady.""" + try: + return await coro + except RadiothermTstatError as ex: + msg = f"{host} was busy (invalid value returned): {ex}" + raise ConfigEntryNotReady(msg) from ex + except (OSError, URLError) as ex: + msg = f"{host} connection error: {ex}" + raise ConfigEntryNotReady(msg) from ex + except timeout as ex: + msg = f"{host} timed out waiting for a response: {ex}" + raise ConfigEntryNotReady(msg) from ex + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Radio Thermostat from a config entry.""" + host = entry.data[CONF_HOST] + init_coro = async_get_init_data(hass, host) + init_data = await _async_call_or_raise_not_ready(init_coro, host) + coordinator = RadioThermUpdateCoordinator(hass, init_data) + await coordinator.async_config_entry_first_refresh() + + # Only set the time if the thermostat is + # not in hold mode since setting the time + # clears the hold for some strange design + # choice + if not coordinator.data.tstat["hold"]: + time_coro = async_set_time(hass, init_data.tstat) + await _async_call_or_raise_not_ready(time_coro, host) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + 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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index dbf013ffa9a..3f8e87e74a4 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from socket import timeout +from typing import Any import radiotherm import voluptuous as vol @@ -18,24 +18,26 @@ from homeassistant.components.climate.const import ( HVACAction, HVACMode, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, PRECISION_HALVES, TEMP_FAHRENHEIT, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback 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 + +from . import DOMAIN +from .coordinator import RadioThermUpdateCoordinator +from .entity import RadioThermostatEntity _LOGGER = logging.getLogger(__name__) ATTR_FAN_ACTION = "fan_action" -CONF_HOLD_TEMP = "hold_temp" - PRESET_HOLIDAY = "holiday" PRESET_ALTERNATE = "alternate" @@ -74,11 +76,19 @@ CODE_TO_TEMP_STATE = {0: HVACAction.IDLE, 1: HVACAction.HEATING, 2: HVACAction.C # future this should probably made into a binary sensor for the fan. CODE_TO_FAN_STATE = {0: FAN_OFF, 1: FAN_ON} -PRESET_MODE_TO_CODE = {"home": 0, "alternate": 1, "away": 2, "holiday": 3} +PRESET_MODE_TO_CODE = { + PRESET_HOME: 0, + PRESET_ALTERNATE: 1, + PRESET_AWAY: 2, + PRESET_HOLIDAY: 3, +} -CODE_TO_PRESET_MODE = {0: "home", 1: "alternate", 2: "away", 3: "holiday"} +CODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_CODE.items()} -CODE_TO_HOLD_STATE = {0: False, 1: True} + +PARALLEL_UPDATES = 1 + +CONF_HOLD_TEMP = "hold_temp" def round_temp(temperature): @@ -98,278 +108,167 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate for a radiotherm device.""" + coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RadioThermostat(coordinator)]) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Radio Thermostat.""" - hosts = [] + _LOGGER.warning( + # config flow added in 2022.7 and should be removed in 2022.9 + "Configuration of the Radio Thermostat climate platform in YAML is deprecated and " + "will be removed in Home Assistant 2022.9; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hosts: list[str] = [] if CONF_HOST in config: hosts = config[CONF_HOST] else: - hosts.append(radiotherm.discover.discover_address()) + hosts.append( + await hass.async_add_executor_job(radiotherm.discover.discover_address) + ) - if hosts is None: + if not hosts: _LOGGER.error("No Radiotherm Thermostats detected") - return False - - hold_temp = config.get(CONF_HOLD_TEMP) - tstats = [] + return for host in hosts: - try: - tstat = radiotherm.get_thermostat(host) - tstats.append(RadioThermostat(tstat, hold_temp)) - except OSError: - _LOGGER.exception("Unable to connect to Radio Thermostat: %s", host) - - add_entities(tstats, True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: host}, + ) + ) -class RadioThermostat(ClimateEntity): +class RadioThermostat(RadioThermostatEntity, ClimateEntity): """Representation of a Radio Thermostat.""" _attr_hvac_modes = OPERATION_LIST - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - ) + _attr_temperature_unit = TEMP_FAHRENHEIT + _attr_precision = PRECISION_HALVES - def __init__(self, device, hold_temp): + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" - self.device = device - self._target_temperature = None - self._current_temperature = None - self._current_humidity = None - self._current_operation = HVACMode.OFF - self._name = None - self._fmode = None - self._fstate = None - self._tmode = None - self._tstate: HVACAction | None = None - self._hold_temp = hold_temp - self._hold_set = False - self._prev_temp = None - self._preset_mode = None - self._program_mode = None - self._is_away = False + super().__init__(coordinator) + self._attr_name = self.init_data.name + self._attr_unique_id = self.init_data.mac + self._attr_fan_modes = CT30_FAN_OPERATION_LIST + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if not isinstance(self.device, radiotherm.thermostat.CT80): + return + self._attr_fan_modes = CT80_FAN_OPERATION_LIST + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + self._attr_preset_modes = PRESET_MODES - # Fan circulate mode is only supported by the CT80 models. - self._is_model_ct80 = isinstance(self.device, radiotherm.thermostat.CT80) - - async def async_added_to_hass(self): - """Register callbacks.""" - # Set the time on the device. This shouldn't be in the - # constructor because it's a network call. We can't put it in - # update() because calling it will clear any temporary mode or - # temperature in the thermostat. So add it as a future job - # for the event loop to run. - self.hass.async_add_job(self.set_time) - - @property - def name(self): - """Return the name of the Radio Thermostat.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - - @property - def precision(self): - """Return the precision of the system.""" - return PRECISION_HALVES - - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - return {ATTR_FAN_ACTION: self._fstate} - - @property - def fan_modes(self): - """List of available fan modes.""" - if self._is_model_ct80: - return CT80_FAN_OPERATION_LIST - return CT30_FAN_OPERATION_LIST - - @property - def fan_mode(self): - """Return whether the fan is on.""" - return self._fmode - - def set_fan_mode(self, fan_mode): + async def async_set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" - if (code := FAN_MODE_TO_CODE.get(fan_mode)) is not None: - self.device.fmode = code + if (code := FAN_MODE_TO_CODE.get(fan_mode)) is None: + raise ValueError(f"{fan_mode} is not a valid fan mode") + await self.hass.async_add_executor_job(self._set_fan_mode, code) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature + def _set_fan_mode(self, code: int) -> None: + """Turn fan on/off.""" + self.device.fmode = code - @property - def current_humidity(self): - """Return the current temperature.""" - return self._current_humidity - - @property - def hvac_mode(self) -> HVACMode: - """Return the current operation. head, cool idle.""" - return self._current_operation - - @property - def hvac_action(self) -> HVACAction | None: - """Return the current running hvac operation if supported.""" - if self.hvac_mode == HVACMode.OFF: - return None - return self._tstate - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - if self._program_mode == 0: - return PRESET_HOME - if self._program_mode == 1: - return PRESET_ALTERNATE - if self._program_mode == 2: - return PRESET_AWAY - if self._program_mode == 3: - return PRESET_HOLIDAY - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return PRESET_MODES - - def update(self): + @callback + def _process_data(self) -> None: """Update and validate the data from the thermostat.""" - # Radio thermostats are very slow, and sometimes don't respond - # very quickly. So we need to keep the number of calls to them - # to a bare minimum or we'll hit the Home Assistant 10 sec warning. We - # have to make one call to /tstat to get temps but we'll try and - # keep the other calls to a minimum. Even with this, these - # thermostats tend to time out sometimes when they're actively - # heating or cooling. - - try: - # First time - get the name from the thermostat. This is - # normally set in the radio thermostat web app. - if self._name is None: - self._name = self.device.name["raw"] - - # Request the current state from the thermostat. - data = self.device.tstat["raw"] - - if self._is_model_ct80: - humiditydata = self.device.humidity["raw"] - - except radiotherm.validate.RadiothermTstatError: - _LOGGER.warning( - "%s (%s) was busy (invalid value returned)", - self._name, - self.device.host, - ) - - except timeout: - _LOGGER.warning( - "Timeout waiting for response from %s (%s)", - self._name, - self.device.host, - ) - + data = self.data.tstat + if isinstance(self.device, radiotherm.thermostat.CT80): + self._attr_current_humidity = self.data.humidity + self._attr_preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] + # Map thermostat values into various STATE_ flags. + self._attr_current_temperature = data["temp"] + self._attr_fan_mode = CODE_TO_FAN_MODE[data["fmode"]] + self._attr_extra_state_attributes = { + ATTR_FAN_ACTION: CODE_TO_FAN_STATE[data["fstate"]] + } + self._attr_hvac_mode = CODE_TO_TEMP_MODE[data["tmode"]] + if self.hvac_mode == HVACMode.OFF: + self._attr_hvac_action = None else: - if self._is_model_ct80: - self._current_humidity = humiditydata - self._program_mode = data["program_mode"] - self._preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] + self._attr_hvac_action = CODE_TO_TEMP_STATE[data["tstate"]] + if self.hvac_mode == HVACMode.COOL: + self._attr_target_temperature = data["t_cool"] + elif self.hvac_mode == HVACMode.HEAT: + self._attr_target_temperature = data["t_heat"] + elif self.hvac_mode == HVACMode.AUTO: + # This doesn't really work - tstate is only set if the HVAC is + # active. If it's idle, we don't know what to do with the target + # temperature. + if self.hvac_action == HVACAction.COOLING: + self._attr_target_temperature = data["t_cool"] + elif self.hvac_action == HVACAction.HEATING: + self._attr_target_temperature = data["t_heat"] - # Map thermostat values into various STATE_ flags. - self._current_temperature = data["temp"] - self._fmode = CODE_TO_FAN_MODE[data["fmode"]] - self._fstate = CODE_TO_FAN_STATE[data["fstate"]] - self._tmode = CODE_TO_TEMP_MODE[data["tmode"]] - self._tstate = CODE_TO_TEMP_STATE[data["tstate"]] - self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] - - self._current_operation = self._tmode - if self._tmode == HVACMode.COOL: - self._target_temperature = data["t_cool"] - elif self._tmode == HVACMode.HEAT: - self._target_temperature = data["t_heat"] - elif self._tmode == HVACMode.AUTO: - # This doesn't really work - tstate is only set if the HVAC is - # active. If it's idle, we don't know what to do with the target - # temperature. - if self._tstate == HVACAction.COOLING: - self._target_temperature = data["t_cool"] - elif self._tstate == HVACAction.HEATING: - self._target_temperature = data["t_heat"] - else: - self._current_operation = HVACMode.OFF - - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return + await self.hass.async_add_executor_job(self._set_temperature, temperature) + self._attr_target_temperature = temperature + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + def _set_temperature(self, temperature: int) -> None: + """Set new target temperature.""" temperature = round_temp(temperature) - - if self._current_operation == HVACMode.COOL: + if self.hvac_mode == HVACMode.COOL: self.device.t_cool = temperature - elif self._current_operation == HVACMode.HEAT: + elif self.hvac_mode == HVACMode.HEAT: self.device.t_heat = temperature - elif self._current_operation == HVACMode.AUTO: - if self._tstate == HVACAction.COOLING: + elif self.hvac_mode == HVACMode.AUTO: + if self.hvac_action == HVACAction.COOLING: self.device.t_cool = temperature - elif self._tstate == HVACAction.HEATING: + elif self.hvac_action == HVACAction.HEATING: self.device.t_heat = temperature - # Only change the hold if requested or if hold mode was turned - # on and we haven't set it yet. - if kwargs.get("hold_changed", False) or not self._hold_set: - if self._hold_temp: - self.device.hold = 1 - self._hold_set = True - else: - self.device.hold = 0 + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set operation mode (auto, cool, heat, off).""" + await self.hass.async_add_executor_job(self._set_hvac_mode, hvac_mode) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() - def set_time(self): - """Set device time.""" - # Calling this clears any local temperature override and - # reverts to the scheduled temperature. - now = dt_util.now() - self.device.time = { - "day": now.weekday(), - "hour": now.hour, - "minute": now.minute, - } - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + def _set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set operation mode (auto, cool, heat, off).""" if hvac_mode in (HVACMode.OFF, HVACMode.AUTO): self.device.tmode = TEMP_MODE_TO_CODE[hvac_mode] - # Setting t_cool or t_heat automatically changes tmode. elif hvac_mode == HVACMode.COOL: - self.device.t_cool = self._target_temperature + self.device.t_cool = self.target_temperature elif hvac_mode == HVACMode.HEAT: - self.device.t_heat = self._target_temperature + self.device.t_heat = self.target_temperature - def set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set Preset mode (Home, Alternate, Away, Holiday).""" - if preset_mode in PRESET_MODES: - self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] - else: - _LOGGER.error( - "Preset_mode %s not in PRESET_MODES", - preset_mode, - ) + if preset_mode not in PRESET_MODES: + raise ValueError(f"{preset_mode} is not a valid preset_mode") + await self.hass.async_add_executor_job(self._set_preset_mode, preset_mode) + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + def _set_preset_mode(self, preset_mode: str) -> None: + """Set Preset mode (Home, Alternate, Away, Holiday).""" + assert isinstance(self.device, radiotherm.thermostat.CT80) + self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py new file mode 100644 index 00000000000..97ae2c1be0a --- /dev/null +++ b/homeassistant/components/radiotherm/config_flow.py @@ -0,0 +1,133 @@ +"""Config flow for Radio Thermostat integration.""" +from __future__ import annotations + +import logging +from socket import timeout +from typing import Any +from urllib.error import URLError + +from radiotherm.validate import RadiothermTstatError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .data import RadioThermInitData, async_get_init_data + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +async def validate_connection(hass: HomeAssistant, host: str) -> RadioThermInitData: + """Validate the connection.""" + try: + return await async_get_init_data(hass, host) + except (timeout, RadiothermTstatError, URLError, OSError) as ex: + raise CannotConnect(f"Failed to connect to {host}: {ex}") from ex + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Radio Thermostat.""" + + VERSION = 1 + + def __init__(self): + """Initialize ConfigFlow.""" + self.discovered_ip: str | None = None + self.discovered_init_data: RadioThermInitData | None = None + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Discover via DHCP.""" + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + try: + init_data = await validate_connection(self.hass, discovery_info.ip) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(init_data.mac) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.ip}, reload_on_update=False + ) + self.discovered_init_data = init_data + self.discovered_ip = discovery_info.ip + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Attempt to confirm.""" + ip_address = self.discovered_ip + init_data = self.discovered_init_data + assert ip_address is not None + assert init_data is not None + if user_input is not None: + return self.async_create_entry( + title=init_data.name, + data={CONF_HOST: ip_address}, + ) + + self._set_confirm_only() + placeholders = { + "name": init_data.name, + "host": self.discovered_ip, + "model": init_data.model or "Unknown", + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="confirm", + description_placeholders=placeholders, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Import from yaml.""" + host = import_info[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + _LOGGER.debug("Importing entry for host: %s", host) + try: + init_data = await validate_connection(self.hass, host) + except CannotConnect as ex: + _LOGGER.debug("Importing failed for %s", host, exc_info=ex) + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(init_data.mac, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host}, reload_on_update=False + ) + return self.async_create_entry( + title=init_data.name, + data={CONF_HOST: import_info[CONF_HOST]}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + init_data = await validate_connection(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(init_data.mac, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]}, + reload_on_update=False, + ) + return self.async_create_entry( + title=init_data.name, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) diff --git a/homeassistant/components/radiotherm/const.py b/homeassistant/components/radiotherm/const.py new file mode 100644 index 00000000000..db097d40665 --- /dev/null +++ b/homeassistant/components/radiotherm/const.py @@ -0,0 +1,5 @@ +"""Constants for the Radio Thermostat integration.""" + +DOMAIN = "radiotherm" + +TIMEOUT = 25 diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py new file mode 100644 index 00000000000..91acdee8710 --- /dev/null +++ b/homeassistant/components/radiotherm/coordinator.py @@ -0,0 +1,47 @@ +"""Coordinator for radiotherm.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from socket import timeout +from urllib.error import URLError + +from radiotherm.validate import RadiothermTstatError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .data import RadioThermInitData, RadioThermUpdate, async_get_data + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=15) + + +class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): + """DataUpdateCoordinator to gather data for radio thermostats.""" + + def __init__(self, hass: HomeAssistant, init_data: RadioThermInitData) -> None: + """Initialize DataUpdateCoordinator.""" + self.init_data = init_data + self._description = f"{init_data.name} ({init_data.host})" + super().__init__( + hass, + _LOGGER, + name=f"radiotherm {self.init_data.name}", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> RadioThermUpdate: + """Update data from the thermostat.""" + try: + return await async_get_data(self.hass, self.init_data.tstat) + except RadiothermTstatError as ex: + msg = f"{self._description} was busy (invalid value returned): {ex}" + raise UpdateFailed(msg) from ex + except (OSError, URLError) as ex: + msg = f"{self._description} connection error: {ex}" + raise UpdateFailed(msg) from ex + except timeout as ex: + msg = f"{self._description}) timed out waiting for a response: {ex}" + raise UpdateFailed(msg) from ex diff --git a/homeassistant/components/radiotherm/data.py b/homeassistant/components/radiotherm/data.py new file mode 100644 index 00000000000..3aa4e6b7631 --- /dev/null +++ b/homeassistant/components/radiotherm/data.py @@ -0,0 +1,74 @@ +"""The radiotherm component data.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import radiotherm +from radiotherm.thermostat import CommonThermostat + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import TIMEOUT + + +@dataclass +class RadioThermUpdate: + """An update from a radiotherm device.""" + + tstat: dict[str, Any] + humidity: int | None + + +@dataclass +class RadioThermInitData: + """An data needed to init the integration.""" + + tstat: CommonThermostat + host: str + name: str + mac: str + model: str | None + fw_version: str | None + api_version: int | None + + +def _get_init_data(host: str) -> RadioThermInitData: + tstat = radiotherm.get_thermostat(host) + tstat.timeout = TIMEOUT + name: str = tstat.name["raw"] + sys: dict[str, Any] = tstat.sys["raw"] + mac: str = dr.format_mac(sys["uuid"]) + model: str = tstat.model.get("raw") + return RadioThermInitData( + tstat, host, name, mac, model, sys.get("fw_version"), sys.get("api_version") + ) + + +async def async_get_init_data(hass: HomeAssistant, host: str) -> RadioThermInitData: + """Get the RadioInitData.""" + return await hass.async_add_executor_job(_get_init_data, host) + + +def _get_data(device: CommonThermostat) -> RadioThermUpdate: + # Request the current state from the thermostat. + # Radio thermostats are very slow, and sometimes don't respond + # very quickly. So we need to keep the number of calls to them + # to a bare minimum or we'll hit the Home Assistant 10 sec warning. We + # have to make one call to /tstat to get temps but we'll try and + # keep the other calls to a minimum. Even with this, these + # thermostats tend to time out sometimes when they're actively + # heating or cooling. + tstat: dict[str, Any] = device.tstat["raw"] + humidity: int | None = None + if isinstance(device, radiotherm.thermostat.CT80): + humidity = device.humidity["raw"] + return RadioThermUpdate(tstat, humidity) + + +async def async_get_data( + hass: HomeAssistant, device: CommonThermostat +) -> RadioThermUpdate: + """Fetch the data from the thermostat.""" + return await hass.async_add_executor_job(_get_data, device) diff --git a/homeassistant/components/radiotherm/entity.py b/homeassistant/components/radiotherm/entity.py new file mode 100644 index 00000000000..203d17a5dc2 --- /dev/null +++ b/homeassistant/components/radiotherm/entity.py @@ -0,0 +1,44 @@ +"""The radiotherm integration base entity.""" + +from abc import abstractmethod + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import RadioThermUpdateCoordinator +from .data import RadioThermUpdate + + +class RadioThermostatEntity(CoordinatorEntity[RadioThermUpdateCoordinator]): + """Base class for radiotherm entities.""" + + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.init_data = coordinator.init_data + self.device = coordinator.init_data.tstat + self._attr_device_info = DeviceInfo( + name=self.init_data.name, + model=self.init_data.model, + manufacturer="Radio Thermostats", + sw_version=self.init_data.fw_version, + connections={(dr.CONNECTION_NETWORK_MAC, self.init_data.mac)}, + ) + self._process_data() + + @property + def data(self) -> RadioThermUpdate: + """Returnt the last update.""" + return self.coordinator.data + + @callback + @abstractmethod + def _process_data(self) -> None: + """Update and validate the data from the thermostat.""" + + @callback + def _handle_coordinator_update(self) -> None: + self._process_data() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json index 72c2c8eb300..c6ae4e5bb06 100644 --- a/homeassistant/components/radiotherm/manifest.json +++ b/homeassistant/components/radiotherm/manifest.json @@ -3,7 +3,12 @@ "name": "Radio Thermostat", "documentation": "https://www.home-assistant.io/integrations/radiotherm", "requirements": ["radiotherm==2.1.0"], - "codeowners": ["@vinnyfuria"], + "codeowners": ["@bdraco", "@vinnyfuria"], "iot_class": "local_polling", - "loggers": ["radiotherm"] + "loggers": ["radiotherm"], + "dhcp": [ + { "hostname": "thermostat*", "macaddress": "5CDAD4*" }, + { "registered_devices": true } + ], + "config_flow": true } diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json new file mode 100644 index 00000000000..22f17224285 --- /dev/null +++ b/homeassistant/components/radiotherm/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "{name} {model} ({host})", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Set a permanent hold when adjusting the temperature." + } + } + } + } +} diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py new file mode 100644 index 00000000000..2cf0602a3fa --- /dev/null +++ b/homeassistant/components/radiotherm/switch.py @@ -0,0 +1,65 @@ +"""Support for radiotherm switches.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import RadioThermUpdateCoordinator +from .entity import RadioThermostatEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches for a radiotherm device.""" + coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RadioThermHoldSwitch(coordinator)]) + + +class RadioThermHoldSwitch(RadioThermostatEntity, SwitchEntity): + """Provides radiotherm hold switch support.""" + + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: + """Initialize the hold mode switch.""" + super().__init__(coordinator) + self._attr_name = f"{coordinator.init_data.name} Hold" + self._attr_unique_id = f"{coordinator.init_data.mac}_hold" + + @property + def icon(self) -> str: + """Return the icon for the switch.""" + return "mdi:timer-off" if self.is_on else "mdi:timer" + + @callback + def _process_data(self) -> None: + """Update and validate the data from the thermostat.""" + data = self.data.tstat + self._attr_is_on = bool(data["hold"]) + + def _set_hold(self, hold: bool) -> None: + """Set hold mode.""" + self.device.hold = int(hold) + + async def _async_set_hold(self, hold: bool) -> None: + """Set hold mode.""" + await self.hass.async_add_executor_job(self._set_hold, hold) + self._attr_is_on = hold + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable permanent hold.""" + await self._async_set_hold(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable permanent hold.""" + await self._async_set_hold(False) diff --git a/homeassistant/components/radiotherm/translations/bg.json b/homeassistant/components/radiotherm/translations/bg.json new file mode 100644 index 00000000000..e588b6014ae --- /dev/null +++ b/homeassistant/components/radiotherm/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/ca.json b/homeassistant/components/radiotherm/translations/ca.json new file mode 100644 index 00000000000..d1249ddd23f --- /dev/null +++ b/homeassistant/components/radiotherm/translations/ca.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Vols configurar {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Configura bloqueig permanent quan s'ajusti la temperatura." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/de.json b/homeassistant/components/radiotherm/translations/de.json new file mode 100644 index 00000000000..50315afce52 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "M\u00f6chtest du {name} {model} ({host}) einrichten?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Stelle beim Einstellen der Temperatur eine permanente Sperre ein." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/el.json b/homeassistant/components/radiotherm/translations/el.json new file mode 100644 index 00000000000..9b276da3670 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/el.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} {model} ({host});" + }, + "user": { + "data": { + "host": "\u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u039f\u03c1\u03af\u03c3\u03c4\u03b5 \u03bc\u03b9\u03b1 \u03bc\u03cc\u03bd\u03b9\u03bc\u03b7 \u03b1\u03bd\u03b1\u03bc\u03bf\u03bd\u03ae \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b8\u03b5\u03c1\u03bc\u03bf\u03ba\u03c1\u03b1\u03c3\u03af\u03b1\u03c2." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/en.json b/homeassistant/components/radiotherm/translations/en.json new file mode 100644 index 00000000000..b524f188e59 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Set a permanent hold when adjusting the temperature." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/es.json b/homeassistant/components/radiotherm/translations/es.json new file mode 100644 index 00000000000..dbb84376ff9 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Error al conectar", + "unknown": "Error inesperado" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u00bfQuieres configurar {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Establezca una retenci\u00f3n permanente al ajustar la temperatura." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/et.json b/homeassistant/components/radiotherm/translations/et.json new file mode 100644 index 00000000000..f8a6a7303ab --- /dev/null +++ b/homeassistant/components/radiotherm/translations/et.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{name} {model} ( {host} )", + "step": { + "confirm": { + "description": "Kas seadistada {name}{model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Temperatuuri reguleerimisel m\u00e4\u00e4ra p\u00fcsiv hoidmine." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/fr.json b/homeassistant/components/radiotherm/translations/fr.json new file mode 100644 index 00000000000..47705ce5138 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Voulez-vous configurer {name} {model} ({host})\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "D\u00e9finissez un maintien permanent lors du r\u00e9glage de la temp\u00e9rature." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/he.json b/homeassistant/components/radiotherm/translations/he.json new file mode 100644 index 00000000000..77232a68dd2 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "flow_title": "{name} {model} ({host})" + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/hu.json b/homeassistant/components/radiotherm/translations/hu.json new file mode 100644 index 00000000000..f55ea666f5f --- /dev/null +++ b/homeassistant/components/radiotherm/translations/hu.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "C\u00edm" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u00c1ll\u00edtson be \u00e1lland\u00f3 tart\u00e1st a h\u0151m\u00e9rs\u00e9klet be\u00e1ll\u00edt\u00e1sakor." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/id.json b/homeassistant/components/radiotherm/translations/id.json new file mode 100644 index 00000000000..1e454cc8cc8 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/id.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Ingin menyiapkan {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Atur penahan permanen saat menyesuaikan suhu." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/it.json b/homeassistant/components/radiotherm/translations/it.json new file mode 100644 index 00000000000..653dd56321b --- /dev/null +++ b/homeassistant/components/radiotherm/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Vuoi configurare {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Imposta un blocco permanente quando si regola la temperatura." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/ja.json b/homeassistant/components/radiotherm/translations/ja.json new file mode 100644 index 00000000000..b792d0a1c9b --- /dev/null +++ b/homeassistant/components/radiotherm/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "{name} {model} ({host}) \u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u884c\u3044\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u6e29\u5ea6\u3092\u8abf\u6574\u3059\u308b\u3068\u304d\u306f\u3001\u6c38\u7d9a\u7684\u306a\u30db\u30fc\u30eb\u30c9(Permanent hold)\u3092\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/nl.json b/homeassistant/components/radiotherm/translations/nl.json new file mode 100644 index 00000000000..6e23671fe02 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Wilt u {name} {model} ({host}) instellen?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Stel een permanente hold in bij het aanpassen van de temperatuur." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/no.json b/homeassistant/components/radiotherm/translations/no.json new file mode 100644 index 00000000000..fc05e672cbe --- /dev/null +++ b/homeassistant/components/radiotherm/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "flow_title": "{name} {model} ( {host} )", + "step": { + "confirm": { + "description": "Vil du konfigurere {name} {model} ( {host} )?" + }, + "user": { + "data": { + "host": "Vert" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Still inn et permanent hold n\u00e5r du justerer temperaturen." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/pl.json b/homeassistant/components/radiotherm/translations/pl.json new file mode 100644 index 00000000000..e69568131d6 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Ustaw podtrzymanie podczas ustawiania temperatury." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/pt-BR.json b/homeassistant/components/radiotherm/translations/pt-BR.json new file mode 100644 index 00000000000..da10f6bd457 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/pt-BR.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falha ao conectar", + "unknown": "Erro inesperado" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Deseja configurar {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Nome do host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Defina uma reten\u00e7\u00e3o permanente ao ajustar a temperatura." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/sv.json b/homeassistant/components/radiotherm/translations/sv.json new file mode 100644 index 00000000000..f341a6314ee --- /dev/null +++ b/homeassistant/components/radiotherm/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/tr.json b/homeassistant/components/radiotherm/translations/tr.json new file mode 100644 index 00000000000..f8e6b4f7a6d --- /dev/null +++ b/homeassistant/components/radiotherm/translations/tr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "{name} {model} ( {host} ) kurulumu yapmak istiyor musunuz?" + }, + "user": { + "data": { + "host": "Sunucu" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "S\u0131cakl\u0131\u011f\u0131 ayarlarken kal\u0131c\u0131 bir bekletme ayarlay\u0131n." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/uk.json b/homeassistant/components/radiotherm/translations/uk.json new file mode 100644 index 00000000000..51459878ddb --- /dev/null +++ b/homeassistant/components/radiotherm/translations/uk.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f", + "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0457\u0442\u0438 {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/zh-Hant.json b/homeassistant/components/radiotherm/translations/zh-Hant.json new file mode 100644 index 00000000000..7a5a40817f7 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} {model} ({host})\uff1f" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u8abf\u6574\u6eab\u5ea6\u6642\u8a2d\u5b9a\u6c38\u4e45\u4fdd\u6301\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/util.py b/homeassistant/components/radiotherm/util.py new file mode 100644 index 00000000000..85b927d7935 --- /dev/null +++ b/homeassistant/components/radiotherm/util.py @@ -0,0 +1,24 @@ +"""Utils for radiotherm.""" +from __future__ import annotations + +from radiotherm.thermostat import CommonThermostat + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + + +async def async_set_time(hass: HomeAssistant, device: CommonThermostat) -> None: + """Sync time to the thermostat.""" + await hass.async_add_executor_job(_set_time, device) + + +def _set_time(device: CommonThermostat) -> None: + """Set device time.""" + # Calling this clears any local temperature override and + # reverts to the scheduled temperature. + now = dt_util.now() + device.time = { + "day": now.weekday(), + "hour": now.hour, + "minute": now.minute, + } diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 1144ceea159..7550756f8c4 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -3,10 +3,12 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta +from typing import Any, cast from regenmaschine.controller import Controller from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -24,6 +26,7 @@ from . import RainMachineEntity from .const import ( DATA_CONTROLLER, DATA_COORDINATOR, + DATA_PROGRAMS, DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_UNIVERSAL, DATA_ZONES, @@ -44,6 +47,7 @@ TYPE_FLOW_SENSOR_CONSUMED_LITERS = "flow_sensor_consumed_liters" TYPE_FLOW_SENSOR_START_INDEX = "flow_sensor_start_index" TYPE_FLOW_SENSOR_WATERING_CLICKS = "flow_sensor_watering_clicks" TYPE_FREEZE_TEMP = "freeze_protect_temp" +TYPE_PROGRAM_RUN_COMPLETION_TIME = "program_run_completion_time" TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" @@ -143,7 +147,26 @@ async def async_setup_entry( ) ] + program_coordinator = coordinators[DATA_PROGRAMS] zone_coordinator = coordinators[DATA_ZONES] + + for uid, program in program_coordinator.data.items(): + sensors.append( + ProgramTimeRemainingSensor( + entry, + program_coordinator, + zone_coordinator, + controller, + RainMachineSensorDescriptionUid( + key=f"{TYPE_PROGRAM_RUN_COMPLETION_TIME}_{uid}", + name=f"{program['name']} Run Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + uid=uid, + ), + ) + ) + for uid, zone in zone_coordinator.data.items(): sensors.append( ZoneTimeRemainingSensor( @@ -163,6 +186,106 @@ async def async_setup_entry( async_add_entities(sensors) +class TimeRemainingSensor(RainMachineEntity, RestoreSensor): + """Define a sensor that shows the amount of time remaining for an activity.""" + + entity_description: RainMachineSensorDescriptionUid + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + controller: Controller, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, coordinator, controller, description) + + self._current_run_state: RunStates | None = None + self._previous_run_state: RunStates | None = None + + @property + def activity_data(self) -> dict[str, Any]: + """Return the core data for this entity.""" + return cast(dict[str, Any], self.coordinator.data[self.entity_description.uid]) + + @property + def status_key(self) -> str: + """Return the data key that contains the activity status.""" + return "state" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + if restored_data := await self.async_get_last_sensor_data(): + self._attr_native_value = restored_data.native_value + await super().async_added_to_hass() + + def calculate_seconds_remaining(self) -> int: + """Calculate the number of seconds remaining.""" + raise NotImplementedError + + @callback + def update_from_latest_data(self) -> None: + """Update the state.""" + self._previous_run_state = self._current_run_state + self._current_run_state = RUN_STATE_MAP.get(self.activity_data[self.status_key]) + + now = utcnow() + + if ( + self._current_run_state == RunStates.NOT_RUNNING + and self._previous_run_state in (RunStates.QUEUED, RunStates.RUNNING) + ): + # If the activity goes from queued/running to not running, update the + # state to be right now (i.e., the time the zone stopped running): + self._attr_native_value = now + elif self._current_run_state == RunStates.RUNNING: + seconds_remaining = self.calculate_seconds_remaining() + new_timestamp = now + timedelta(seconds=seconds_remaining) + + assert isinstance(self._attr_native_value, datetime) + + if ( + self._attr_native_value + and new_timestamp - self._attr_native_value + < DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE + ): + # If the deviation between the previous and new timestamps is less + # than a "wobble tolerance," don't spam the state machine: + return + + self._attr_native_value = new_timestamp + + +class ProgramTimeRemainingSensor(TimeRemainingSensor): + """Define a sensor that shows the amount of time remaining for a program.""" + + def __init__( + self, + entry: ConfigEntry, + program_coordinator: DataUpdateCoordinator, + zone_coordinator: DataUpdateCoordinator, + controller: Controller, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, program_coordinator, controller, description) + + self._zone_coordinator = zone_coordinator + + @property + def status_key(self) -> str: + """Return the data key that contains the activity status.""" + return "status" + + def calculate_seconds_remaining(self) -> int: + """Calculate the number of seconds remaining.""" + return sum( + self._zone_coordinator.data[zone["id"]]["remaining"] + for zone in [z for z in self.activity_data["wateringTimes"] if z["active"]] + ) + + class ProvisionSettingsSensor(RainMachineEntity, SensorEntity): """Define a sensor that handles provisioning data.""" @@ -203,47 +326,11 @@ class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): self._attr_native_value = self.coordinator.data.get("freezeProtectTemp") -class ZoneTimeRemainingSensor(RainMachineEntity, SensorEntity): +class ZoneTimeRemainingSensor(TimeRemainingSensor): """Define a sensor that shows the amount of time remaining for a zone.""" - entity_description: RainMachineSensorDescriptionUid - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - controller: Controller, - description: EntityDescription, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinator, controller, description) - - self._running_or_queued: bool = False - - @callback - def update_from_latest_data(self) -> None: - """Update the state.""" - data = self.coordinator.data[self.entity_description.uid] - now = utcnow() - - if RUN_STATE_MAP.get(data["state"]) == RunStates.NOT_RUNNING: - if self._running_or_queued: - # If we go from running to not running, update the state to be right - # now (i.e., the time the zone stopped running): - self._attr_native_value = now - self._running_or_queued = False - return - - self._running_or_queued = True - new_timestamp = now + timedelta(seconds=data["remaining"]) - - if self._attr_native_value: - assert isinstance(self._attr_native_value, datetime) - if ( - new_timestamp - self._attr_native_value - ) < DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE: - # If the deviation between the previous and new timestamps is less than - # a "wobble tolerance," don't spam the state machine: - return - - self._attr_native_value = new_timestamp + def calculate_seconds_remaining(self) -> int: + """Calculate the number of seconds remaining.""" + return cast( + int, self.coordinator.data[self.entity_description.uid]["remaining"] + ) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index e558d19b530..532644c7feb 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,12 +1,10 @@ """Recorder constants.""" -from functools import partial -import json -from typing import Final - from homeassistant.backports.enum import StrEnum from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-import + JSON_DUMP, +) DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" @@ -27,8 +25,6 @@ MAX_ROWS_TO_PURGE = 998 DB_WORKER_PREFIX = "DbWorker" -JSON_DUMP: Final = partial(json.dumps, cls=JSONEncoder, separators=(",", ":")) - ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} ATTR_KEEP_DAYS = "keep_days" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7a096a9c404..9585804690a 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -36,6 +36,7 @@ from homeassistant.helpers.event import ( async_track_time_interval, async_track_utc_time_change, ) +from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util @@ -48,17 +49,19 @@ from .const import ( SQLITE_URL_PREFIX, SupportedDialect, ) -from .executor import DBInterruptibleThreadPoolExecutor -from .models import ( +from .db_schema import ( SCHEMA_VERSION, Base, EventData, Events, StateAttributes, States, + StatisticsRuns, +) +from .executor import DBInterruptibleThreadPoolExecutor +from .models import ( StatisticData, StatisticMetaData, - StatisticsRuns, UnsupportedDialect, process_timestamp, ) @@ -752,11 +755,12 @@ class Recorder(threading.Thread): return try: - shared_data = EventData.shared_data_from_event(event) - except (TypeError, ValueError) as ex: + shared_data_bytes = EventData.shared_data_bytes_from_event(event) + except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) return + shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit if pending_event_data := self._pending_event_data.get(shared_data): dbevent.event_data_rel = pending_event_data @@ -764,7 +768,7 @@ class Recorder(threading.Thread): elif data_id := self._event_data_ids.get(shared_data): dbevent.data_id = data_id else: - data_hash = EventData.hash_shared_data(shared_data) + data_hash = EventData.hash_shared_data_bytes(shared_data_bytes) # Matching attributes found in the database if data_id := self._find_shared_data_in_db(data_hash, shared_data): self._event_data_ids[shared_data] = dbevent.data_id = data_id @@ -783,10 +787,10 @@ class Recorder(threading.Thread): assert self.event_session is not None try: dbstate = States.from_event(event) - shared_attrs = StateAttributes.shared_attrs_from_event( + shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event( event, self._exclude_attributes_by_domain ) - except (TypeError, ValueError) as ex: + except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning( "State is not JSON serializable: %s: %s", event.data.get("new_state"), @@ -794,6 +798,7 @@ class Recorder(threading.Thread): ) return + shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit if pending_attributes := self._pending_state_attributes.get(shared_attrs): @@ -802,7 +807,7 @@ class Recorder(threading.Thread): elif attributes_id := self._state_attributes_ids.get(shared_attrs): dbstate.attributes_id = attributes_id else: - attr_hash = StateAttributes.hash_shared_attrs(shared_attrs) + attr_hash = StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) # Matching attributes found in the database if attributes_id := self._find_shared_attr_in_db(attr_hash, shared_attrs): dbstate.attributes_id = attributes_id diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py new file mode 100644 index 00000000000..36487353e25 --- /dev/null +++ b/homeassistant/components/recorder/db_schema.py @@ -0,0 +1,605 @@ +"""Models for SQLAlchemy.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +import logging +from typing import Any, cast + +import ciso8601 +from fnvhash import fnv1a_32 +from sqlalchemy import ( + JSON, + BigInteger, + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Identity, + Index, + Integer, + SmallInteger, + String, + Text, + distinct, + type_coerce, +) +from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import aliased, declarative_base, relationship +from sqlalchemy.orm.session import Session + +from homeassistant.const import ( + MAX_LENGTH_EVENT_CONTEXT_ID, + MAX_LENGTH_EVENT_EVENT_TYPE, + MAX_LENGTH_EVENT_ORIGIN, + MAX_LENGTH_STATE_ENTITY_ID, + MAX_LENGTH_STATE_STATE, +) +from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.json import ( + JSON_DECODE_EXCEPTIONS, + JSON_DUMP, + json_bytes, + json_loads, +) +import homeassistant.util.dt as dt_util + +from .const import ALL_DOMAIN_EXCLUDE_ATTRS +from .models import StatisticData, StatisticMetaData, process_timestamp + +# SQLAlchemy Schema +# pylint: disable=invalid-name +Base = declarative_base() + +SCHEMA_VERSION = 29 + +_LOGGER = logging.getLogger(__name__) + +TABLE_EVENTS = "events" +TABLE_EVENT_DATA = "event_data" +TABLE_STATES = "states" +TABLE_STATE_ATTRIBUTES = "state_attributes" +TABLE_RECORDER_RUNS = "recorder_runs" +TABLE_SCHEMA_CHANGES = "schema_changes" +TABLE_STATISTICS = "statistics" +TABLE_STATISTICS_META = "statistics_meta" +TABLE_STATISTICS_RUNS = "statistics_runs" +TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" + +ALL_TABLES = [ + TABLE_STATES, + TABLE_STATE_ATTRIBUTES, + TABLE_EVENTS, + TABLE_EVENT_DATA, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, + TABLE_STATISTICS, + TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, +] + +TABLES_TO_CHECK = [ + TABLE_STATES, + TABLE_EVENTS, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, +] + +LAST_UPDATED_INDEX = "ix_states_last_updated" +ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated" +EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" +STATES_CONTEXT_ID_INDEX = "ix_states_context_id" + + +class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): # type: ignore[misc] + """Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex.""" + + def result_processor(self, dialect, coltype): # type: ignore[no-untyped-def] + """Offload the datetime parsing to ciso8601.""" + return lambda value: None if value is None else ciso8601.parse_datetime(value) + + +JSON_VARIENT_CAST = Text().with_variant( + postgresql.JSON(none_as_null=True), "postgresql" +) +JSONB_VARIENT_CAST = Text().with_variant( + postgresql.JSONB(none_as_null=True), "postgresql" +) +DATETIME_TYPE = ( + DateTime(timezone=True) + .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql") + .with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") +) +DOUBLE_TYPE = ( + Float() + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") + .with_variant(oracle.DOUBLE_PRECISION(), "oracle") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") +) + + +class JSONLiteral(JSON): # type: ignore[misc] + """Teach SA how to literalize json.""" + + def literal_processor(self, dialect: str) -> Callable[[Any], str]: + """Processor to convert a value to JSON.""" + + def process(value: Any) -> str: + """Dump json.""" + return JSON_DUMP(value) + + return process + + +EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] +EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} + + +class Events(Base): # type: ignore[misc,valid-type] + """Event history data.""" + + __table_args__ = ( + # Used for fetching events at a specific time + # see logbook + Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENTS + event_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used for new rows + origin_idx = Column(SmallInteger) + time_fired = Column(DATETIME_TYPE, index=True) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) + event_data_rel = relationship("EventData") + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> Events: + """Create an event database object from a native event.""" + return Events( + event_type=event.event_type, + event_data=None, + origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + ) + + def to_native(self, validate_entity_id: bool = True) -> Event | None: + """Convert to a native HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + return Event( + self.event_type, + json_loads(self.event_data) if self.event_data else {}, + EventOrigin(self.origin) + if self.origin + else EVENT_ORIGIN_ORDER[self.origin_idx], + process_timestamp(self.time_fired), + context=context, + ) + except JSON_DECODE_EXCEPTIONS: + # When json_loads fails + _LOGGER.exception("Error converting to event: %s", self) + return None + + +class EventData(Base): # type: ignore[misc,valid-type] + """Event data history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_DATA + data_id = Column(Integer, Identity(), primary_key=True) + hash = Column(BigInteger, index=True) + # Note that this is not named attributes to avoid confusion with the states table + shared_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> EventData: + """Create object from an event.""" + shared_data = json_bytes(event.data) + return EventData( + shared_data=shared_data.decode("utf-8"), + hash=EventData.hash_shared_data_bytes(shared_data), + ) + + @staticmethod + def shared_data_bytes_from_event(event: Event) -> bytes: + """Create shared_data from an event.""" + return json_bytes(event.data) + + @staticmethod + def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: + """Return the hash of json encoded shared data.""" + return cast(int, fnv1a_32(shared_data_bytes)) + + def to_native(self) -> dict[str, Any]: + """Convert to an HA state object.""" + try: + return cast(dict[str, Any], json_loads(self.shared_data)) + except JSON_DECODE_EXCEPTIONS: + _LOGGER.exception("Error converting row to event data: %s", self) + return {} + + +class States(Base): # type: ignore[misc,valid-type] + """State change history.""" + + __table_args__ = ( + # Used for fetching the state of entities at a specific time + # (get_states in history.py) + Index(ENTITY_ID_LAST_UPDATED_INDEX, "entity_id", "last_updated"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES + state_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + state = Column(String(MAX_LENGTH_STATE_STATE)) + attributes = Column( + Text().with_variant(mysql.LONGTEXT, "mysql") + ) # no longer used for new rows + event_id = Column( # no longer used for new rows + Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True + ) + last_changed = Column(DATETIME_TYPE) + last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + attributes_id = Column( + Integer, ForeignKey("state_attributes.attributes_id"), index=True + ) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + old_state = relationship("States", remote_side=[state_id]) + state_attributes = relationship("StateAttributes") + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> States: + """Create object from a state_changed event.""" + entity_id = event.data["entity_id"] + state: State | None = event.data.get("new_state") + dbstate = States( + entity_id=entity_id, + attributes=None, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), + ) + + # None state means the state was removed from the state machine + if state is None: + dbstate.state = "" + dbstate.last_updated = event.time_fired + dbstate.last_changed = None + return dbstate + + dbstate.state = state.state + dbstate.last_updated = state.last_updated + if state.last_updated == state.last_changed: + dbstate.last_changed = None + else: + dbstate.last_changed = state.last_changed + + return dbstate + + def to_native(self, validate_entity_id: bool = True) -> State | None: + """Convert to an HA state object.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + attrs = json_loads(self.attributes) if self.attributes else {} + except JSON_DECODE_EXCEPTIONS: + # When json_loads fails + _LOGGER.exception("Error converting row to state: %s", self) + return None + if self.last_changed is None or self.last_changed == self.last_updated: + last_changed = last_updated = process_timestamp(self.last_updated) + else: + last_updated = process_timestamp(self.last_updated) + last_changed = process_timestamp(self.last_changed) + return State( + self.entity_id, + self.state, + # Join the state_attributes table on attributes_id to get the attributes + # for newer states + attrs, + last_changed, + last_updated, + context=context, + validate_entity_id=validate_entity_id, + ) + + +class StateAttributes(Base): # type: ignore[misc,valid-type] + """State attribute change history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATE_ATTRIBUTES + attributes_id = Column(Integer, Identity(), primary_key=True) + hash = Column(BigInteger, index=True) + # Note that this is not named attributes to avoid confusion with the states table + shared_attrs = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> StateAttributes: + """Create object from a state_changed event.""" + state: State | None = event.data.get("new_state") + # None state means the state was removed from the state machine + attr_bytes = b"{}" if state is None else json_bytes(state.attributes) + dbstate = StateAttributes(shared_attrs=attr_bytes.decode("utf-8")) + dbstate.hash = StateAttributes.hash_shared_attrs_bytes(attr_bytes) + return dbstate + + @staticmethod + def shared_attrs_bytes_from_event( + event: Event, exclude_attrs_by_domain: dict[str, set[str]] + ) -> bytes: + """Create shared_attrs from a state_changed event.""" + state: State | None = event.data.get("new_state") + # 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 = ( + exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS + ) + return json_bytes( + {k: v for k, v in state.attributes.items() if k not in exclude_attrs} + ) + + @staticmethod + def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: + """Return the hash of json encoded shared attributes.""" + return cast(int, fnv1a_32(shared_attrs_bytes)) + + def to_native(self) -> dict[str, Any]: + """Convert to an HA state object.""" + try: + return cast(dict[str, Any], json_loads(self.shared_attrs)) + except JSON_DECODE_EXCEPTIONS: + # When json_loads fails + _LOGGER.exception("Error converting row to state attributes: %s", self) + return {} + + +class StatisticsBase: + """Statistics base class.""" + + id = Column(Integer, Identity(), primary_key=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + + @declared_attr # type: ignore[misc] + def metadata_id(self) -> Column: + """Define the metadata_id column for sub classes.""" + return Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) + + start = Column(DATETIME_TYPE, index=True) + mean = Column(DOUBLE_TYPE) + min = Column(DOUBLE_TYPE) + max = Column(DOUBLE_TYPE) + last_reset = Column(DATETIME_TYPE) + state = Column(DOUBLE_TYPE) + sum = Column(DOUBLE_TYPE) + + @classmethod + def from_stats(cls, metadata_id: int, stats: StatisticData) -> StatisticsBase: + """Create object from a statistics.""" + return cls( # type: ignore[call-arg,misc] + metadata_id=metadata_id, + **stats, + ) + + +class Statistics(Base, StatisticsBase): # type: ignore[misc,valid-type] + """Long term statistics.""" + + duration = timedelta(hours=1) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start", unique=True), + ) + __tablename__ = TABLE_STATISTICS + + +class StatisticsShortTerm(Base, StatisticsBase): # type: ignore[misc,valid-type] + """Short term statistics.""" + + duration = timedelta(minutes=5) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index( + "ix_statistics_short_term_statistic_id_start", + "metadata_id", + "start", + unique=True, + ), + ) + __tablename__ = TABLE_STATISTICS_SHORT_TERM + + +class StatisticsMeta(Base): # type: ignore[misc,valid-type] + """Statistics meta data.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATISTICS_META + id = Column(Integer, Identity(), primary_key=True) + statistic_id = Column(String(255), index=True, unique=True) + source = Column(String(32)) + unit_of_measurement = Column(String(255)) + has_mean = Column(Boolean) + has_sum = Column(Boolean) + name = Column(String(255)) + + @staticmethod + def from_meta(meta: StatisticMetaData) -> StatisticsMeta: + """Create object from meta data.""" + return StatisticsMeta(**meta) + + +class RecorderRuns(Base): # type: ignore[misc,valid-type] + """Representation of recorder run.""" + + __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + __tablename__ = TABLE_RECORDER_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) + end = Column(DateTime(timezone=True)) + closed_incorrect = Column(Boolean, default=False) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + end = ( + f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None + ) + return ( + f"" + ) + + def entity_ids(self, point_in_time: datetime | None = None) -> list[str]: + """Return the entity ids that existed in this run. + + Specify point_in_time if you want to know which existed at that point + in time inside the run. + """ + session = Session.object_session(self) + + assert session is not None, "RecorderRuns need to be persisted" + + query = session.query(distinct(States.entity_id)).filter( + States.last_updated >= self.start + ) + + if point_in_time is not None: + query = query.filter(States.last_updated < point_in_time) + elif self.end is not None: + query = query.filter(States.last_updated < self.end) + + return [row[0] for row in query] + + def to_native(self, validate_entity_id: bool = True) -> RecorderRuns: + """Return self, native format is this model.""" + return self + + +class SchemaChanges(Base): # type: ignore[misc,valid-type] + """Representation of schema version changes.""" + + __tablename__ = TABLE_SCHEMA_CHANGES + change_id = Column(Integer, Identity(), primary_key=True) + schema_version = Column(Integer) + changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +class StatisticsRuns(Base): # type: ignore[misc,valid-type] + """Representation of statistics run.""" + + __tablename__ = TABLE_STATISTICS_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), index=True) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +EVENT_DATA_JSON = type_coerce( + EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) +OLD_FORMAT_EVENT_DATA_JSON = type_coerce( + Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) + +SHARED_ATTRS_JSON = type_coerce( + StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) +OLD_FORMAT_ATTRS_JSON = type_coerce( + States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) + +ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] +OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] +DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] +OLD_STATE = aliased(States, name="old_state") diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 0b3e0e68030..45db64e0097 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.typing import ConfigType -from .models import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States +from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States DOMAIN = "history" HISTORY_FILTERS = "history_filters" @@ -139,49 +139,52 @@ class Filters: have_exclude = self._have_exclude have_include = self._have_include - # Case 1 - no includes or excludes - pass all entities + # Case 1 - No filter + # - All entities included if not have_include and not have_exclude: return None - # Case 2 - includes, no excludes - only include specified entities + # Case 2 - Only includes + # - Entity listed in entities include: include + # - Otherwise, entity matches domain include: include + # - Otherwise, entity matches glob include: include + # - Otherwise: exclude if have_include and not have_exclude: return or_(*includes).self_group() - # Case 3 - excludes, no includes - only exclude specified entities + # Case 3 - Only excludes + # - Entity listed in exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise: include if not have_include and have_exclude: return not_(or_(*excludes).self_group()) - # Case 4 - both includes and excludes specified - # Case 4a - include domain or glob specified - # - if domain is included, pass if entity not excluded - # - if glob is included, pass if entity and domain not excluded - # - if domain and glob are not included, pass if entity is included - # note: if both include domain matches then exclude domains ignored. - # If glob matches then exclude domains and glob checked + # Case 4 - Domain and/or glob includes (may also have excludes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in entities exclude: exclude + # - Otherwise, entity matches glob include: include + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain include: include + # - Otherwise: exclude if self.included_domains or self.included_entity_globs: return or_( - (i_domains & ~(e_entities | e_entity_globs)), - ( - ~i_domains - & or_( - (i_entity_globs & ~(or_(*excludes))), - (~i_entity_globs & i_entities), - ) - ), + i_entities, + (~e_entities & (i_entity_globs | (~e_entity_globs & i_domains))), ).self_group() - # Case 4b - exclude domain or glob specified, include has no domain or glob - # In this one case the traditional include logic is inverted. Even though an - # include is specified since its only a list of entity IDs its used only to - # expose specific entities excluded by domain or glob. Any entities not - # excluded are then presumed included. Logic is as follows - # - if domain or glob is excluded, pass if entity is included - # - if domain is not excluded, pass if entity not excluded by ID + # Case 5 - Domain and/or glob excludes (no domain and/or glob includes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise: include if self.excluded_domains or self.excluded_entity_globs: return (not_(or_(*excludes)) | i_entities).self_group() - # Case 4c - neither include or exclude domain specified - # - Only pass if entity is included. Ignore entity excludes. + # Case 6 - No Domain and/or glob includes or excludes + # - Entity listed in entities include: include + # - Otherwise: exclude return i_entities def states_entity_filter(self) -> ClauseList: diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index b3fff62ae03..e1eca282a3a 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -9,11 +9,13 @@ import logging import time from typing import Any, cast -from sqlalchemy import Column, Text, and_, func, or_, select +from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select from sqlalchemy.engine.row import Row +from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal -from sqlalchemy.sql.selectable import Select, Subquery +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Subquery from homeassistant.components import recorder from homeassistant.components.websocket_api.const import ( @@ -23,18 +25,16 @@ from homeassistant.components.websocket_api.const import ( from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util +from .db_schema import RecorderRuns, StateAttributes, States from .filters import Filters from .models import ( LazyState, - RecorderRuns, - StateAttributes, - States, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, row_to_compressed_state, ) -from .util import execute_stmt, session_scope +from .util import execute_stmt_lambda_element, session_scope # mypy: allow-untyped-defs, no-check-untyped-defs @@ -114,18 +114,22 @@ def _schema_version(hass: HomeAssistant) -> int: return recorder.get_instance(hass).schema_version -def stmt_and_join_attributes( +def lambda_stmt_and_join_attributes( schema_version: int, no_attributes: bool, include_last_changed: bool = True -) -> tuple[Select, bool]: - """Return the stmt and if StateAttributes should be joined.""" +) -> tuple[StatementLambdaElement, bool]: + """Return the lambda_stmt and if StateAttributes should be joined. + + Because these are lambda_stmt the values inside the lambdas need + to be explicitly written out to avoid caching the wrong values. + """ # If no_attributes was requested we do the query # without the attributes fields and do not join the # state_attributes table if no_attributes: if include_last_changed: - return select(*QUERY_STATE_NO_ATTR), False + return lambda_stmt(lambda: select(*QUERY_STATE_NO_ATTR)), False return ( - select(*QUERY_STATE_NO_ATTR_NO_LAST_CHANGED), + lambda_stmt(lambda: select(*QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), False, ) # If we in the process of migrating schema we do @@ -134,19 +138,19 @@ def stmt_and_join_attributes( if schema_version < 25: if include_last_changed: return ( - select(*QUERY_STATES_PRE_SCHEMA_25), + lambda_stmt(lambda: select(*QUERY_STATES_PRE_SCHEMA_25)), False, ) return ( - select(*QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED), + lambda_stmt(lambda: select(*QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED)), False, ) # Finally if no migration is in progress and no_attributes # was not requested, we query both attributes columns and # join state_attributes if include_last_changed: - return select(*QUERY_STATES), True - return select(*QUERY_STATES_NO_LAST_CHANGED), True + return lambda_stmt(lambda: select(*QUERY_STATES)), True + return lambda_stmt(lambda: select(*QUERY_STATES_NO_LAST_CHANGED)), True def get_significant_states( @@ -178,7 +182,7 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Select) -> Select: +def _ignore_domains_filter(query: Query) -> Query: """Add a filter to ignore domains we do not fetch history for.""" return query.filter( and_( @@ -198,9 +202,9 @@ def _significant_states_stmt( filters: Filters | None, significant_changes_only: bool, no_attributes: bool, -) -> Select: +) -> StatementLambdaElement: """Query the database for significant state changes.""" - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( @@ -209,11 +213,11 @@ def _significant_states_stmt( and significant_changes_only and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS ): - stmt = stmt.filter( + stmt += lambda q: q.filter( (States.last_changed == States.last_updated) | States.last_changed.is_(None) ) elif significant_changes_only: - stmt = stmt.filter( + stmt += lambda q: q.filter( or_( *[ States.entity_id.like(entity_domain) @@ -227,22 +231,25 @@ def _significant_states_stmt( ) if entity_ids: - stmt = stmt.filter(States.entity_id.in_(entity_ids)) + stmt += lambda q: q.filter(States.entity_id.in_(entity_ids)) else: - stmt = _ignore_domains_filter(stmt) + stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt = stmt.filter(entity_filter) + stmt = stmt.add_criteria( + lambda q: q.filter(entity_filter), track_on=[filters] + ) - stmt = stmt.filter(States.last_updated > start_time) + stmt += lambda q: q.filter(States.last_updated > start_time) if end_time: - stmt = stmt.filter(States.last_updated < end_time) + stmt += lambda q: q.filter(States.last_updated < end_time) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - return stmt.order_by(States.entity_id, States.last_updated) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated) + return stmt def get_significant_states_with_session( @@ -279,7 +286,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, ) - states = execute_stmt(session, stmt, None if entity_ids else start_time, end_time) + states = execute_stmt_lambda_element( + session, stmt, None if entity_ids else start_time, end_time + ) return _sorted_states_to_dict( hass, session, @@ -331,28 +340,28 @@ def _state_changed_during_period_stmt( no_attributes: bool, descending: bool, limit: int | None, -) -> Select: - stmt, join_attributes = stmt_and_join_attributes( +) -> StatementLambdaElement: + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=False ) - stmt = stmt.filter( + stmt += lambda q: q.filter( ((States.last_changed == States.last_updated) | States.last_changed.is_(None)) & (States.last_updated > start_time) ) if end_time: - stmt = stmt.filter(States.last_updated < end_time) + stmt += lambda q: q.filter(States.last_updated < end_time) if entity_id: - stmt = stmt.filter(States.entity_id == entity_id) + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) if descending: - stmt = stmt.order_by(States.entity_id, States.last_updated.desc()) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()) else: - stmt = stmt.order_by(States.entity_id, States.last_updated) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated) if limit: - stmt = stmt.limit(limit) + stmt += lambda q: q.limit(limit) return stmt @@ -380,7 +389,7 @@ def state_changes_during_period( descending, limit, ) - states = execute_stmt( + states = execute_stmt_lambda_element( session, stmt, None if entity_id else start_time, end_time ) return cast( @@ -398,22 +407,23 @@ def state_changes_during_period( def _get_last_state_changes_stmt( schema_version: int, number_of_states: int, entity_id: str | None -) -> Select: - stmt, join_attributes = stmt_and_join_attributes( +) -> StatementLambdaElement: + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, False, include_last_changed=False ) - stmt = stmt.filter( + stmt += lambda q: q.filter( (States.last_changed == States.last_updated) | States.last_changed.is_(None) ) if entity_id: - stmt = stmt.filter(States.entity_id == entity_id) + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - return stmt.order_by(States.entity_id, States.last_updated.desc()).limit( + stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()).limit( number_of_states ) + return stmt def get_last_state_changes( @@ -428,7 +438,7 @@ def get_last_state_changes( stmt = _get_last_state_changes_stmt( _schema_version(hass), number_of_states, entity_id ) - states = list(execute_stmt(session, stmt)) + states = list(execute_stmt_lambda_element(session, stmt)) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -448,14 +458,14 @@ def _get_states_for_entites_stmt( utc_point_in_time: datetime, entity_ids: list[str], no_attributes: bool, -) -> Select: +) -> StatementLambdaElement: """Baked query to get states for specific entities.""" - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - stmt = stmt.where( + stmt += lambda q: q.where( States.state_id == ( select(func.max(States.state_id).label("max_state_id")) @@ -469,7 +479,7 @@ def _get_states_for_entites_stmt( ).c.max_state_id ) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) return stmt @@ -500,9 +510,9 @@ def _get_states_for_all_stmt( utc_point_in_time: datetime, filters: Filters | None, no_attributes: bool, -) -> Select: +) -> StatementLambdaElement: """Baked query to get states for all entities.""" - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We did not get an include-list of entities, query all states in the inner @@ -512,7 +522,7 @@ def _get_states_for_all_stmt( most_recent_states_by_date = _generate_most_recent_states_by_date( run_start, utc_point_in_time ) - stmt = stmt.where( + stmt += lambda q: q.where( States.state_id == ( select(func.max(States.state_id).label("max_state_id")) @@ -528,12 +538,12 @@ def _get_states_for_all_stmt( .subquery() ).c.max_state_id, ) - stmt = _ignore_domains_filter(stmt) + stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt = stmt.filter(entity_filter) + stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) return stmt @@ -551,7 +561,7 @@ def _get_rows_with_session( """Return the states at a specific point in time.""" schema_version = _schema_version(hass) if entity_ids and len(entity_ids) == 1: - return execute_stmt( + return execute_stmt_lambda_element( session, _get_single_entity_states_stmt( schema_version, utc_point_in_time, entity_ids[0], no_attributes @@ -576,7 +586,7 @@ def _get_rows_with_session( schema_version, run.start, utc_point_in_time, filters, no_attributes ) - return execute_stmt(session, stmt) + return execute_stmt_lambda_element(session, stmt) def _get_single_entity_states_stmt( @@ -584,14 +594,14 @@ def _get_single_entity_states_stmt( utc_point_in_time: datetime, entity_id: str, no_attributes: bool = False, -) -> Select: +) -> StatementLambdaElement: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) - stmt = ( - stmt.filter( + stmt += ( + lambda q: q.filter( States.last_updated < utc_point_in_time, States.entity_id == entity_id, ) @@ -599,7 +609,7 @@ def _get_single_entity_states_stmt( .limit(1) ) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) return stmt diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 38897c42e1a..3d22781906a 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -2,7 +2,7 @@ "domain": "recorder", "name": "Recorder", "documentation": "https://www.home-assistant.io/integrations/recorder", - "requirements": ["sqlalchemy==1.4.37", "fnvhash==0.1.0", "lru-dict==1.1.7"], + "requirements": ["sqlalchemy==1.4.38", "fnvhash==0.1.0", "lru-dict==1.1.7"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index cc5af684566..7e11e62502d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -22,7 +22,7 @@ from sqlalchemy.sql.expression import true from homeassistant.core import HomeAssistant from .const import SupportedDialect -from .models import ( +from .db_schema import ( SCHEMA_VERSION, TABLE_STATES, Base, @@ -31,8 +31,8 @@ from .models import ( StatisticsMeta, StatisticsRuns, StatisticsShortTerm, - process_timestamp, ) +from .models import process_timestamp from .statistics import ( delete_statistics_duplicates, delete_statistics_meta_duplicates, diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 8db648f15a8..ff53d9be3d1 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,36 +1,11 @@ -"""Models for SQLAlchemy.""" +"""Models for Recorder.""" from __future__ import annotations -from collections.abc import Callable -from datetime import datetime, timedelta -import json +from datetime import datetime import logging -from typing import Any, TypedDict, cast, overload +from typing import Any, TypedDict, overload -import ciso8601 -from fnvhash import fnv1a_32 -from sqlalchemy import ( - JSON, - BigInteger, - Boolean, - Column, - DateTime, - Float, - ForeignKey, - Identity, - Index, - Integer, - SmallInteger, - String, - Text, - distinct, - type_coerce, -) -from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.row import Row -from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import aliased, declarative_base, relationship -from sqlalchemy.orm.session import Session from homeassistant.components.websocket_api.const import ( COMPRESSED_STATE_ATTRIBUTES, @@ -38,396 +13,23 @@ from homeassistant.components.websocket_api.const import ( COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE, ) -from homeassistant.const import ( - MAX_LENGTH_EVENT_CONTEXT_ID, - MAX_LENGTH_EVENT_EVENT_TYPE, - MAX_LENGTH_EVENT_ORIGIN, - 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, State +from homeassistant.helpers.json import json_loads import homeassistant.util.dt as dt_util -from .const import ALL_DOMAIN_EXCLUDE_ATTRS, JSON_DUMP - -# SQLAlchemy Schema # pylint: disable=invalid-name -Base = declarative_base() - -SCHEMA_VERSION = 29 _LOGGER = logging.getLogger(__name__) DB_TIMEZONE = "+00:00" -TABLE_EVENTS = "events" -TABLE_EVENT_DATA = "event_data" -TABLE_STATES = "states" -TABLE_STATE_ATTRIBUTES = "state_attributes" -TABLE_RECORDER_RUNS = "recorder_runs" -TABLE_SCHEMA_CHANGES = "schema_changes" -TABLE_STATISTICS = "statistics" -TABLE_STATISTICS_META = "statistics_meta" -TABLE_STATISTICS_RUNS = "statistics_runs" -TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" - -ALL_TABLES = [ - TABLE_STATES, - TABLE_STATE_ATTRIBUTES, - TABLE_EVENTS, - TABLE_EVENT_DATA, - TABLE_RECORDER_RUNS, - TABLE_SCHEMA_CHANGES, - TABLE_STATISTICS, - TABLE_STATISTICS_META, - TABLE_STATISTICS_RUNS, - TABLE_STATISTICS_SHORT_TERM, -] - -TABLES_TO_CHECK = [ - TABLE_STATES, - TABLE_EVENTS, - TABLE_RECORDER_RUNS, - TABLE_SCHEMA_CHANGES, -] - -LAST_UPDATED_INDEX = "ix_states_last_updated" -ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated" -EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" -STATES_CONTEXT_ID_INDEX = "ix_states_context_id" - EMPTY_JSON_OBJECT = "{}" -class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): # type: ignore[misc] - """Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex.""" - - def result_processor(self, dialect, coltype): # type: ignore[no-untyped-def] - """Offload the datetime parsing to ciso8601.""" - return lambda value: None if value is None else ciso8601.parse_datetime(value) - - -JSON_VARIENT_CAST = Text().with_variant( - postgresql.JSON(none_as_null=True), "postgresql" -) -JSONB_VARIENT_CAST = Text().with_variant( - postgresql.JSONB(none_as_null=True), "postgresql" -) -DATETIME_TYPE = ( - DateTime(timezone=True) - .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql") - .with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") -) -DOUBLE_TYPE = ( - Float() - .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") - .with_variant(oracle.DOUBLE_PRECISION(), "oracle") - .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") -) - - -class JSONLiteral(JSON): # type: ignore[misc] - """Teach SA how to literalize json.""" - - def literal_processor(self, dialect: str) -> Callable[[Any], str]: - """Processor to convert a value to JSON.""" - - def process(value: Any) -> str: - """Dump json.""" - return json.dumps(value) - - return process - - -EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] -EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} - - class UnsupportedDialect(Exception): """The dialect or its version is not supported.""" -class Events(Base): # type: ignore[misc,valid-type] - """Event history data.""" - - __table_args__ = ( - # Used for fetching events at a specific time - # see logbook - Index("ix_events_event_type_time_fired", "event_type", "time_fired"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_EVENTS - event_id = Column(Integer, Identity(), primary_key=True) - event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) - event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) - origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used for new rows - origin_idx = Column(SmallInteger) - time_fired = Column(DATETIME_TYPE, index=True) - context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) - context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) - event_data_rel = relationship("EventData") - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> Events: - """Create an event database object from a native event.""" - return Events( - event_type=event.event_type, - event_data=None, - origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), - time_fired=event.time_fired, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, - ) - - def to_native(self, validate_entity_id: bool = True) -> Event | None: - """Convert to a native HA Event.""" - context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, - ) - try: - return Event( - self.event_type, - json.loads(self.event_data) if self.event_data else {}, - EventOrigin(self.origin) - if self.origin - else EVENT_ORIGIN_ORDER[self.origin_idx], - process_timestamp(self.time_fired), - context=context, - ) - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting to event: %s", self) - return None - - -class EventData(Base): # type: ignore[misc,valid-type] - """Event data history.""" - - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_EVENT_DATA - data_id = Column(Integer, Identity(), primary_key=True) - hash = Column(BigInteger, index=True) - # Note that this is not named attributes to avoid confusion with the states table - shared_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> EventData: - """Create object from an event.""" - shared_data = JSON_DUMP(event.data) - return EventData( - shared_data=shared_data, hash=EventData.hash_shared_data(shared_data) - ) - - @staticmethod - def shared_data_from_event(event: Event) -> str: - """Create shared_attrs from an event.""" - return JSON_DUMP(event.data) - - @staticmethod - def hash_shared_data(shared_data: str) -> int: - """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data.encode("utf-8"))) - - def to_native(self) -> dict[str, Any]: - """Convert to an HA state object.""" - try: - return cast(dict[str, Any], json.loads(self.shared_data)) - except ValueError: - _LOGGER.exception("Error converting row to event data: %s", self) - return {} - - -class States(Base): # type: ignore[misc,valid-type] - """State change history.""" - - __table_args__ = ( - # Used for fetching the state of entities at a specific time - # (get_states in history.py) - Index(ENTITY_ID_LAST_UPDATED_INDEX, "entity_id", "last_updated"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_STATES - state_id = Column(Integer, Identity(), primary_key=True) - entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) - state = Column(String(MAX_LENGTH_STATE_STATE)) - attributes = Column( - Text().with_variant(mysql.LONGTEXT, "mysql") - ) # no longer used for new rows - event_id = Column( # no longer used for new rows - Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True - ) - last_changed = Column(DATETIME_TYPE) - last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) - old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) - attributes_id = Column( - Integer, ForeignKey("state_attributes.attributes_id"), index=True - ) - context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) - context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - origin_idx = Column(SmallInteger) # 0 is local, 1 is remote - old_state = relationship("States", remote_side=[state_id]) - state_attributes = relationship("StateAttributes") - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> States: - """Create object from a state_changed event.""" - entity_id = event.data["entity_id"] - state: State | None = event.data.get("new_state") - dbstate = States( - entity_id=entity_id, - attributes=None, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, - origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), - ) - - # None state means the state was removed from the state machine - if state is None: - dbstate.state = "" - dbstate.last_updated = event.time_fired - dbstate.last_changed = None - return dbstate - - dbstate.state = state.state - dbstate.last_updated = state.last_updated - if state.last_updated == state.last_changed: - dbstate.last_changed = None - else: - dbstate.last_changed = state.last_changed - - return dbstate - - def to_native(self, validate_entity_id: bool = True) -> State | None: - """Convert to an HA state object.""" - context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, - ) - try: - attrs = json.loads(self.attributes) if self.attributes else {} - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting row to state: %s", self) - return None - if self.last_changed is None or self.last_changed == self.last_updated: - last_changed = last_updated = process_timestamp(self.last_updated) - else: - last_updated = process_timestamp(self.last_updated) - last_changed = process_timestamp(self.last_changed) - return State( - self.entity_id, - self.state, - # Join the state_attributes table on attributes_id to get the attributes - # for newer states - attrs, - last_changed, - last_updated, - context=context, - validate_entity_id=validate_entity_id, - ) - - -class StateAttributes(Base): # type: ignore[misc,valid-type] - """State attribute change history.""" - - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_STATE_ATTRIBUTES - attributes_id = Column(Integer, Identity(), primary_key=True) - hash = Column(BigInteger, index=True) - # Note that this is not named attributes to avoid confusion with the states table - shared_attrs = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> StateAttributes: - """Create object from a state_changed event.""" - state: State | None = event.data.get("new_state") - # None state means the state was removed from the state machine - dbstate = StateAttributes( - shared_attrs="{}" if state is None else JSON_DUMP(state.attributes) - ) - dbstate.hash = StateAttributes.hash_shared_attrs(dbstate.shared_attrs) - return dbstate - - @staticmethod - def shared_attrs_from_event( - event: Event, exclude_attrs_by_domain: dict[str, set[str]] - ) -> str: - """Create shared_attrs from a state_changed event.""" - state: State | None = event.data.get("new_state") - # None state means the state was removed from the state machine - if state is None: - return "{}" - domain = split_entity_id(state.entity_id)[0] - exclude_attrs = ( - exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS - ) - return JSON_DUMP( - {k: v for k, v in state.attributes.items() if k not in exclude_attrs} - ) - - @staticmethod - def hash_shared_attrs(shared_attrs: str) -> int: - """Return the hash of json encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs.encode("utf-8"))) - - def to_native(self) -> dict[str, Any]: - """Convert to an HA state object.""" - try: - return cast(dict[str, Any], json.loads(self.shared_attrs)) - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting row to state attributes: %s", self) - return {} - - class StatisticResult(TypedDict): """Statistic result data class. @@ -455,67 +57,6 @@ class StatisticData(StatisticDataBase, total=False): sum: float -class StatisticsBase: - """Statistics base class.""" - - id = Column(Integer, Identity(), primary_key=True) - created = Column(DATETIME_TYPE, default=dt_util.utcnow) - - @declared_attr # type: ignore[misc] - def metadata_id(self) -> Column: - """Define the metadata_id column for sub classes.""" - return Column( - Integer, - ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), - index=True, - ) - - start = Column(DATETIME_TYPE, index=True) - mean = Column(DOUBLE_TYPE) - min = Column(DOUBLE_TYPE) - max = Column(DOUBLE_TYPE) - last_reset = Column(DATETIME_TYPE) - state = Column(DOUBLE_TYPE) - sum = Column(DOUBLE_TYPE) - - @classmethod - def from_stats(cls, metadata_id: int, stats: StatisticData) -> StatisticsBase: - """Create object from a statistics.""" - return cls( # type: ignore[call-arg,misc] - metadata_id=metadata_id, - **stats, - ) - - -class Statistics(Base, StatisticsBase): # type: ignore[misc,valid-type] - """Long term statistics.""" - - duration = timedelta(hours=1) - - __table_args__ = ( - # Used for fetching statistics for a certain entity at a specific time - Index("ix_statistics_statistic_id_start", "metadata_id", "start", unique=True), - ) - __tablename__ = TABLE_STATISTICS - - -class StatisticsShortTerm(Base, StatisticsBase): # type: ignore[misc,valid-type] - """Short term statistics.""" - - duration = timedelta(minutes=5) - - __table_args__ = ( - # Used for fetching statistics for a certain entity at a specific time - Index( - "ix_statistics_short_term_statistic_id_start", - "metadata_id", - "start", - unique=True, - ), - ) - __tablename__ = TABLE_STATISTICS_SHORT_TERM - - class StatisticMetaData(TypedDict): """Statistic meta data class.""" @@ -527,131 +68,6 @@ class StatisticMetaData(TypedDict): unit_of_measurement: str | None -class StatisticsMeta(Base): # type: ignore[misc,valid-type] - """Statistics meta data.""" - - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_STATISTICS_META - id = Column(Integer, Identity(), primary_key=True) - statistic_id = Column(String(255), index=True, unique=True) - source = Column(String(32)) - unit_of_measurement = Column(String(255)) - has_mean = Column(Boolean) - has_sum = Column(Boolean) - name = Column(String(255)) - - @staticmethod - def from_meta(meta: StatisticMetaData) -> StatisticsMeta: - """Create object from meta data.""" - return StatisticsMeta(**meta) - - -class RecorderRuns(Base): # type: ignore[misc,valid-type] - """Representation of recorder run.""" - - __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) - __tablename__ = TABLE_RECORDER_RUNS - run_id = Column(Integer, Identity(), primary_key=True) - start = Column(DateTime(timezone=True), default=dt_util.utcnow) - end = Column(DateTime(timezone=True)) - closed_incorrect = Column(Boolean, default=False) - created = Column(DateTime(timezone=True), default=dt_util.utcnow) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - end = ( - f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None - ) - return ( - f"" - ) - - def entity_ids(self, point_in_time: datetime | None = None) -> list[str]: - """Return the entity ids that existed in this run. - - Specify point_in_time if you want to know which existed at that point - in time inside the run. - """ - session = Session.object_session(self) - - assert session is not None, "RecorderRuns need to be persisted" - - query = session.query(distinct(States.entity_id)).filter( - States.last_updated >= self.start - ) - - if point_in_time is not None: - query = query.filter(States.last_updated < point_in_time) - elif self.end is not None: - query = query.filter(States.last_updated < self.end) - - return [row[0] for row in query] - - def to_native(self, validate_entity_id: bool = True) -> RecorderRuns: - """Return self, native format is this model.""" - return self - - -class SchemaChanges(Base): # type: ignore[misc,valid-type] - """Representation of schema version changes.""" - - __tablename__ = TABLE_SCHEMA_CHANGES - change_id = Column(Integer, Identity(), primary_key=True) - schema_version = Column(Integer) - changed = Column(DateTime(timezone=True), default=dt_util.utcnow) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - -class StatisticsRuns(Base): # type: ignore[misc,valid-type] - """Representation of statistics run.""" - - __tablename__ = TABLE_STATISTICS_RUNS - run_id = Column(Integer, Identity(), primary_key=True) - start = Column(DateTime(timezone=True), index=True) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - -EVENT_DATA_JSON = type_coerce( - EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) -OLD_FORMAT_EVENT_DATA_JSON = type_coerce( - Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) - -SHARED_ATTRS_JSON = type_coerce( - StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) -OLD_FORMAT_ATTRS_JSON = type_coerce( - States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) - -ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] -OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] -DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] -OLD_STATE = aliased(States, name="old_state") - - @overload def process_timestamp(ts: None) -> None: ... @@ -837,7 +253,7 @@ def decode_attributes_from_row( if not source or source == EMPTY_JSON_OBJECT: return {} try: - attr_cache[source] = attributes = json.loads(source) + attr_cache[source] = attributes = json_loads(source) except ValueError: _LOGGER.exception("Error converting row to state attributes: %s", source) attr_cache[source] = attributes = {} diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 52b6b74dfa1..a8579df834c 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -87,9 +87,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] exclude_integrations={"recorder"}, error_if_core=False, ) - return super( # pylint: disable=bad-super-call - NullPool, self - )._create_connection() + return super(NullPool, self)._create_connection() class MutexPool(StaticPool): # type: ignore[misc] diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 10136dfb5a6..c470575c5f1 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -14,7 +14,7 @@ from sqlalchemy.sql.expression import distinct from homeassistant.const import EVENT_STATE_CHANGED from .const import MAX_ROWS_TO_PURGE, SupportedDialect -from .models import Events, StateAttributes, States +from .db_schema import Events, StateAttributes, States from .queries import ( attributes_ids_exist_in_states, attributes_ids_exist_in_states_sqlite, diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index e27d3d692cc..4b4488d4dad 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -9,7 +9,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select from .const import MAX_ROWS_TO_PURGE -from .models import ( +from .db_schema import ( EventData, Events, RecorderRuns, diff --git a/homeassistant/components/recorder/repack.py b/homeassistant/components/recorder/repack.py index 1b1d59df37e..53c922cf481 100644 --- a/homeassistant/components/recorder/repack.py +++ b/homeassistant/components/recorder/repack.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from sqlalchemy import text from .const import SupportedDialect -from .models import ALL_TABLES +from .db_schema import ALL_TABLES if TYPE_CHECKING: from . import Recorder diff --git a/homeassistant/components/recorder/run_history.py b/homeassistant/components/recorder/run_history.py index 783aff89c17..fb87d9a1fa2 100644 --- a/homeassistant/components/recorder/run_history.py +++ b/homeassistant/components/recorder/run_history.py @@ -9,7 +9,8 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util -from .models import RecorderRuns, process_timestamp +from .db_schema import RecorderRuns +from .models import process_timestamp def _find_recorder_run_for_start_time( diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9a52a36ab5f..26221aa199b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -14,12 +14,13 @@ import re from statistics import mean from typing import TYPE_CHECKING, Any, Literal, overload -from sqlalchemy import bindparam, func, select +from sqlalchemy import bindparam, func, lambda_stmt, select from sqlalchemy.engine.row import Row from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal_column, true -from sqlalchemy.sql.selectable import Select, Subquery +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Subquery import voluptuous as vol from homeassistant.const import ( @@ -41,18 +42,20 @@ from homeassistant.util.unit_system import UnitSystem import homeassistant.util.volume as volume_util from .const import DATA_INSTANCE, DOMAIN, MAX_ROWS_TO_PURGE, SupportedDialect +from .db_schema import Statistics, StatisticsMeta, StatisticsRuns, StatisticsShortTerm from .models import ( StatisticData, StatisticMetaData, StatisticResult, - Statistics, - StatisticsMeta, - StatisticsRuns, - StatisticsShortTerm, process_timestamp, process_timestamp_to_utc_isoformat, ) -from .util import execute, execute_stmt, retryable_database_job, session_scope +from .util import ( + execute, + execute_stmt_lambda_element, + retryable_database_job, + session_scope, +) if TYPE_CHECKING: from . import Recorder @@ -477,10 +480,10 @@ def delete_statistics_meta_duplicates(session: Session) -> None: def _compile_hourly_statistics_summary_mean_stmt( start_time: datetime, end_time: datetime -) -> Select: +) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" - return ( - select(*QUERY_STATISTICS_SUMMARY_MEAN) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN) .filter(StatisticsShortTerm.start >= start_time) .filter(StatisticsShortTerm.start < end_time) .group_by(StatisticsShortTerm.metadata_id) @@ -503,7 +506,7 @@ def compile_hourly_statistics( # Compute last hour's average, min, max summary: dict[str, StatisticData] = {} stmt = _compile_hourly_statistics_summary_mean_stmt(start_time, end_time) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if stats: for stat in stats: @@ -685,17 +688,17 @@ def _generate_get_metadata_stmt( statistic_ids: list[str] | tuple[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, -) -> Select: +) -> StatementLambdaElement: """Generate a statement to fetch metadata.""" - stmt = select(*QUERY_STATISTIC_META) + stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) if statistic_ids is not None: - stmt = stmt.where(StatisticsMeta.statistic_id.in_(statistic_ids)) + stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: - stmt = stmt.where(StatisticsMeta.source == statistic_source) + stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": - stmt = stmt.where(StatisticsMeta.has_mean == true()) + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) elif statistic_type == "sum": - stmt = stmt.where(StatisticsMeta.has_sum == true()) + stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) return stmt @@ -717,7 +720,7 @@ def get_metadata_with_session( # Fetch metatadata from the database stmt = _generate_get_metadata_stmt(statistic_ids, statistic_type, statistic_source) - result = execute_stmt(session, stmt) + result = execute_stmt_lambda_element(session, stmt) if not result: return {} @@ -979,30 +982,44 @@ def _statistics_during_period_stmt( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, -) -> Select: - """Prepare a database query for statistics during a given period.""" - stmt = select(*QUERY_STATISTICS).filter(Statistics.start >= start_time) +) -> StatementLambdaElement: + """Prepare a database query for statistics during a given period. + + This prepares a lambda_stmt query, so we don't insert the parameters yet. + """ + stmt = lambda_stmt( + lambda: select(*QUERY_STATISTICS).filter(Statistics.start >= start_time) + ) if end_time is not None: - stmt = stmt.filter(Statistics.start < end_time) + stmt += lambda q: q.filter(Statistics.start < end_time) if metadata_ids: - stmt = stmt.filter(Statistics.metadata_id.in_(metadata_ids)) - return stmt.order_by(Statistics.metadata_id, Statistics.start) + stmt += lambda q: q.filter(Statistics.metadata_id.in_(metadata_ids)) + stmt += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) + return stmt def _statistics_during_period_stmt_short_term( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, -) -> Select: - """Prepare a database query for short term statistics during a given period.""" - stmt = select(*QUERY_STATISTICS_SHORT_TERM).filter( - StatisticsShortTerm.start >= start_time +) -> StatementLambdaElement: + """Prepare a database query for short term statistics during a given period. + + This prepares a lambda_stmt query, so we don't insert the parameters yet. + """ + stmt = lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM).filter( + StatisticsShortTerm.start >= start_time + ) ) if end_time is not None: - stmt = stmt.filter(StatisticsShortTerm.start < end_time) + stmt += lambda q: q.filter(StatisticsShortTerm.start < end_time) if metadata_ids: - stmt = stmt.filter(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - return stmt.order_by(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start) + stmt += lambda q: q.filter(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + stmt += lambda q: q.order_by( + StatisticsShortTerm.metadata_id, StatisticsShortTerm.start + ) + return stmt def statistics_during_period( @@ -1037,7 +1054,7 @@ def statistics_during_period( else: table = Statistics stmt = _statistics_during_period_stmt(start_time, end_time, metadata_ids) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if not stats: return {} @@ -1068,10 +1085,10 @@ def statistics_during_period( def _get_last_statistics_stmt( metadata_id: int, number_of_stats: int, -) -> Select: +) -> StatementLambdaElement: """Generate a statement for number_of_stats statistics for a given statistic_id.""" - return ( - select(*QUERY_STATISTICS) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS) .filter_by(metadata_id=metadata_id) .order_by(Statistics.metadata_id, Statistics.start.desc()) .limit(number_of_stats) @@ -1081,10 +1098,10 @@ def _get_last_statistics_stmt( def _get_last_statistics_short_term_stmt( metadata_id: int, number_of_stats: int, -) -> Select: +) -> StatementLambdaElement: """Generate a statement for number_of_stats short term statistics for a given statistic_id.""" - return ( - select(*QUERY_STATISTICS_SHORT_TERM) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM) .filter_by(metadata_id=metadata_id) .order_by(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc()) .limit(number_of_stats) @@ -1110,7 +1127,7 @@ def _get_last_statistics( stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) else: stmt = _get_last_statistics_short_term_stmt(metadata_id, number_of_stats) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if not stats: return {} @@ -1160,11 +1177,11 @@ def _generate_most_recent_statistic_row(metadata_ids: list[int]) -> Subquery: def _latest_short_term_statistics_stmt( metadata_ids: list[int], -) -> Select: +) -> StatementLambdaElement: """Create the statement for finding the latest short term stat rows.""" - stmt = select(*QUERY_STATISTICS_SHORT_TERM) + stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) most_recent_statistic_row = _generate_most_recent_statistic_row(metadata_ids) - return stmt.join( + stmt += lambda s: s.join( most_recent_statistic_row, ( StatisticsShortTerm.metadata_id # pylint: disable=comparison-with-callable @@ -1172,6 +1189,7 @@ def _latest_short_term_statistics_stmt( ) & (StatisticsShortTerm.start == most_recent_statistic_row.c.start_max), ) + return stmt def get_latest_short_term_statistics( @@ -1194,7 +1212,7 @@ def get_latest_short_term_statistics( if statistic_id in metadata ] stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if not stats: return {} diff --git a/homeassistant/components/recorder/translations/bg.json b/homeassistant/components/recorder/translations/bg.json new file mode 100644 index 00000000000..2098a389361 --- /dev/null +++ b/homeassistant/components/recorder/translations/bg.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "database_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 \u0431\u0430\u0437\u0430\u0442\u0430 \u0434\u0430\u043d\u043d\u0438" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/recorder/translations/el.json b/homeassistant/components/recorder/translations/el.json index 6d541820c55..46c585c816d 100644 --- a/homeassistant/components/recorder/translations/el.json +++ b/homeassistant/components/recorder/translations/el.json @@ -2,6 +2,8 @@ "system_health": { "info": { "current_recorder_run": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03ce\u03c1\u03b1 \u03ad\u03bd\u03b1\u03c1\u03be\u03b7\u03c2 \u03b5\u03ba\u03c4\u03ad\u03bb\u03b5\u03c3\u03b7\u03c2", + "database_engine": "\u039c\u03b7\u03c7\u03b1\u03bd\u03ae \u03b2\u03ac\u03c3\u03b7\u03c2 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd", + "database_version": "\u0388\u03ba\u03b4\u03bf\u03c3\u03b7 \u03b2\u03ac\u03c3\u03b7\u03c2 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd", "estimated_db_size": "\u0395\u03ba\u03c4\u03b9\u03bc\u03ce\u03bc\u03b5\u03bd\u03bf \u03bc\u03ad\u03b3\u03b5\u03b8\u03bf\u03c2 \u03b2\u03ac\u03c3\u03b7\u03c2 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd (MiB)", "oldest_recorder_run": "\u03a0\u03b1\u03bb\u03b1\u03b9\u03cc\u03c4\u03b5\u03c1\u03b7 \u03ce\u03c1\u03b1 \u03ad\u03bd\u03b1\u03c1\u03be\u03b7\u03c2 \u03b5\u03ba\u03c4\u03ad\u03bb\u03b5\u03c3\u03b7\u03c2" } diff --git a/homeassistant/components/recorder/translations/es.json b/homeassistant/components/recorder/translations/es.json index 81bcf29d548..86bdd0abec8 100644 --- a/homeassistant/components/recorder/translations/es.json +++ b/homeassistant/components/recorder/translations/es.json @@ -2,7 +2,10 @@ "system_health": { "info": { "current_recorder_run": "Hora de inicio de la ejecuci\u00f3n actual", - "estimated_db_size": "Mida estimada de la base de datos (MiB)" + "database_engine": "Motor de la base de datos", + "database_version": "Versi\u00f3n de la base de datos", + "estimated_db_size": "Mida estimada de la base de datos (MiB)", + "oldest_recorder_run": "Hora de inicio de ejecuci\u00f3n m\u00e1s antigua" } } } \ No newline at end of file diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 9c8a0c1eae3..c1fbc831987 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -22,20 +22,20 @@ from sqlalchemy.engine.row import Row from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session +from sqlalchemy.sql.lambdas import StatementLambdaElement from typing_extensions import Concatenate, ParamSpec from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from .const import DATA_INSTANCE, SQLITE_URL_PREFIX, SupportedDialect -from .models import ( +from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLES_TO_CHECK, RecorderRuns, - UnsupportedDialect, - process_timestamp, ) +from .models import UnsupportedDialect, process_timestamp if TYPE_CHECKING: from . import Recorder @@ -166,9 +166,9 @@ def execute( assert False # unreachable # pragma: no cover -def execute_stmt( +def execute_stmt_lambda_element( session: Session, - query: Query, + stmt: StatementLambdaElement, start_time: datetime | None = None, end_time: datetime | None = None, yield_per: int | None = DEFAULT_YIELD_STATES_ROWS, @@ -184,12 +184,11 @@ def execute_stmt( specific entities) since they are usually faster with .all(). """ + executed = session.execute(stmt) use_all = not start_time or ((end_time or dt_util.utcnow()) - start_time).days <= 1 for tryno in range(0, RETRIES): try: - if use_all: - return session.execute(query).all() # type: ignore[no-any-return] - return session.execute(query).yield_per(yield_per) # type: ignore[no-any-return] + return executed.all() if use_all else executed.yield_per(yield_per) # type: ignore[no-any-return] except SQLAlchemyError as err: _LOGGER.error("Error executing query: %s", err) if tryno == RETRIES - 1: diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d0499fbf9cb..45e2cf5620b 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -1,4 +1,4 @@ -"""The Energy websocket API.""" +"""The Recorder websocket API.""" from __future__ import annotations import logging diff --git a/homeassistant/components/remote/translations/zh-Hans.json b/homeassistant/components/remote/translations/zh-Hans.json index f6c509d4a08..4e9430f84e1 100644 --- a/homeassistant/components/remote/translations/zh-Hans.json +++ b/homeassistant/components/remote/translations/zh-Hans.json @@ -10,6 +10,7 @@ "is_on": "{entity_name} \u5df2\u6253\u5f00" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" } diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 47832cdbe93..8f5b99972d1 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure Renault component.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from renault_api.const import AVAILABLE_LOCALES @@ -21,7 +22,7 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Renault config flow.""" - self._original_data: dict[str, Any] | None = None + self._original_data: Mapping[str, Any] | None = None self.renault_config: dict[str, Any] = {} self.renault_hub: RenaultHub | None = None @@ -92,9 +93,9 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._original_data = user_input.copy() + self._original_data = entry_data return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/renault/translations/sv.json b/homeassistant/components/renault/translations/sv.json new file mode 100644 index 00000000000..26e9f2d6a49 --- /dev/null +++ b/homeassistant/components/renault/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 2beed53522a..bc51433c3c5 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -11,18 +11,20 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import TemplateEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import async_get_config_and_coordinator, create_rest_data_from_config +from .const import DEFAULT_BINARY_SENSOR_NAME from .entity import RestEntity from .schema import BINARY_SENSOR_SCHEMA, RESOURCE_SCHEMA @@ -57,51 +59,55 @@ async def async_setup_platform( raise PlatformNotReady from rest.last_exception raise PlatformNotReady - name = conf.get(CONF_NAME) - device_class = conf.get(CONF_DEVICE_CLASS) - value_template = conf.get(CONF_VALUE_TEMPLATE) - force_update = conf.get(CONF_FORCE_UPDATE) - resource_template = conf.get(CONF_RESOURCE_TEMPLATE) - - if value_template is not None: - value_template.hass = hass + unique_id = conf.get(CONF_UNIQUE_ID) async_add_entities( [ RestBinarySensor( + hass, coordinator, rest, - name, - device_class, - value_template, - force_update, - resource_template, + conf, + unique_id, ) ], ) -class RestBinarySensor(RestEntity, BinarySensorEntity): +class RestBinarySensor(RestEntity, TemplateEntity, BinarySensorEntity): """Representation of a REST binary sensor.""" def __init__( self, + hass, coordinator, rest, - name, - device_class, - value_template, - force_update, - resource_template, + config, + unique_id, ): """Initialize a REST binary sensor.""" - super().__init__(coordinator, rest, name, resource_template, force_update) + RestEntity.__init__( + self, + coordinator, + rest, + config.get(CONF_RESOURCE_TEMPLATE), + config.get(CONF_FORCE_UPDATE), + ) + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=DEFAULT_BINARY_SENSOR_NAME, + unique_id=unique_id, + ) self._state = False self._previous_data = None - self._value_template = value_template + self._value_template = config.get(CONF_VALUE_TEMPLATE) + if (value_template := self._value_template) is not None: + value_template.hass = hass self._is_on = None - self._attr_device_class = device_class + self._attr_device_class = config.get(CONF_DEVICE_CLASS) @property def is_on(self): diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py index 064396af415..5d7a65b3d48 100644 --- a/homeassistant/components/rest/entity.py +++ b/homeassistant/components/rest/entity.py @@ -17,22 +17,14 @@ class RestEntity(Entity): self, coordinator: DataUpdateCoordinator[Any], rest: RestData, - name, resource_template, force_update, ) -> None: """Create the entity that may have a coordinator.""" self.coordinator = coordinator self.rest = rest - self._name = name self._resource_template = resource_template self._force_update = force_update - super().__init__() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name @property def force_update(self): @@ -41,7 +33,7 @@ class RestEntity(Entity): @property def should_poll(self) -> bool: - """Poll only if we do noty have a coordinator.""" + """Poll only if we do not have a coordinator.""" return not self.coordinator @property diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c81656d82b4..f6e7631e623 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -2,7 +2,7 @@ "domain": "rest", "name": "RESTful", "documentation": "https://www.home-assistant.io/integrations/rest", - "requirements": ["jsonpath==0.82", "xmltodict==0.12.0"], + "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index c5b6949bd39..f881dc8b028 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -6,19 +6,13 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, ) -from homeassistant.components.sensor import ( - DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, - DOMAIN as SENSOR_DOMAIN, - STATE_CLASSES_SCHEMA, -) -from homeassistant.components.sensor.const import CONF_STATE_CLASS +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_HEADERS, CONF_METHOD, - CONF_NAME, CONF_PARAMS, CONF_PASSWORD, CONF_PAYLOAD, @@ -26,7 +20,6 @@ from homeassistant.const import ( CONF_RESOURCE_TEMPLATE, CONF_SCAN_INTERVAL, CONF_TIMEOUT, - CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, @@ -34,14 +27,16 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TEMPLATE_SENSOR_BASE_SCHEMA, +) from .const import ( CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, - DEFAULT_BINARY_SENSOR_NAME, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, - DEFAULT_SENSOR_NAME, DEFAULT_VERIFY_SSL, DOMAIN, METHODS, @@ -65,10 +60,7 @@ RESOURCE_SCHEMA = { } SENSOR_SCHEMA = { - vol.Optional(CONF_NAME, default=DEFAULT_SENSOR_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + **TEMPLATE_SENSOR_BASE_SCHEMA.schema, vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -76,7 +68,7 @@ SENSOR_SCHEMA = { } BINARY_SENSOR_SCHEMA = { - vol.Optional(CONF_NAME, default=DEFAULT_BINARY_SENSOR_NAME): cv.string, + **TEMPLATE_ENTITY_BASE_SCHEMA.schema, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 73d65abda7e..ff571b0c9dc 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -1,7 +1,6 @@ """Support for RESTful API sensors.""" from __future__ import annotations -import json import logging from xml.parsers.expat import ExpatError @@ -10,30 +9,28 @@ import voluptuous as vol import xmltodict from homeassistant.components.sensor import ( - CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, - SensorEntity, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( - CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_UNIT_OF_MEASUREMENT, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import json_dumps, json_loads +from homeassistant.helpers.template_entity import TemplateSensor from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import async_get_config_and_coordinator, create_rest_data_from_config -from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH +from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, DEFAULT_SENSOR_NAME from .entity import RestEntity from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA @@ -70,67 +67,54 @@ async def async_setup_platform( raise PlatformNotReady from rest.last_exception raise PlatformNotReady - name = conf.get(CONF_NAME) - unit = conf.get(CONF_UNIT_OF_MEASUREMENT) - device_class = conf.get(CONF_DEVICE_CLASS) - state_class = conf.get(CONF_STATE_CLASS) - json_attrs = conf.get(CONF_JSON_ATTRS) - json_attrs_path = conf.get(CONF_JSON_ATTRS_PATH) - value_template = conf.get(CONF_VALUE_TEMPLATE) - force_update = conf.get(CONF_FORCE_UPDATE) - resource_template = conf.get(CONF_RESOURCE_TEMPLATE) - - if value_template is not None: - value_template.hass = hass + unique_id = conf.get(CONF_UNIQUE_ID) async_add_entities( [ RestSensor( + hass, coordinator, rest, - name, - unit, - device_class, - state_class, - value_template, - json_attrs, - force_update, - resource_template, - json_attrs_path, + conf, + unique_id, ) ], ) -class RestSensor(RestEntity, SensorEntity): +class RestSensor(RestEntity, TemplateSensor): """Implementation of a REST sensor.""" def __init__( self, + hass, coordinator, rest, - name, - unit_of_measurement, - device_class, - state_class, - value_template, - json_attrs, - force_update, - resource_template, - json_attrs_path, + config, + unique_id, ): """Initialize the REST sensor.""" - super().__init__(coordinator, rest, name, resource_template, force_update) + RestEntity.__init__( + self, + coordinator, + rest, + config.get(CONF_RESOURCE_TEMPLATE), + config.get(CONF_FORCE_UPDATE), + ) + TemplateSensor.__init__( + self, + hass, + config=config, + fallback_name=DEFAULT_SENSOR_NAME, + unique_id=unique_id, + ) self._state = None - self._unit_of_measurement = unit_of_measurement - self._value_template = value_template - self._json_attrs = json_attrs + self._value_template = config.get(CONF_VALUE_TEMPLATE) + if (value_template := self._value_template) is not None: + value_template.hass = hass + self._json_attrs = config.get(CONF_JSON_ATTRS) self._attributes = None - self._json_attrs_path = json_attrs_path - - self._attr_native_unit_of_measurement = self._unit_of_measurement - self._attr_device_class = device_class - self._attr_state_class = state_class + self._json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) @property def native_value(self): @@ -157,7 +141,7 @@ class RestSensor(RestEntity, SensorEntity): or content_type.startswith("application/rss+xml") ): try: - value = json.dumps(xmltodict.parse(value)) + value = json_dumps(xmltodict.parse(value)) _LOGGER.debug("JSON converted from XML: %s", value) except ExpatError: _LOGGER.warning( @@ -169,7 +153,7 @@ class RestSensor(RestEntity, SensorEntity): self._attributes = {} if value: try: - json_dict = json.loads(value) + json_dict = json_loads(value) if self._json_attrs_path is not None: json_dict = jsonpath(json_dict, self._json_attrs_path) # jsonpath will always store the result in json_dict[0] diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 10214970cce..c45eb581645 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -18,11 +18,11 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_HEADERS, CONF_METHOD, - CONF_NAME, CONF_PARAMS, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, + CONF_UNIQUE_ID, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -30,6 +30,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -49,6 +53,7 @@ SUPPORT_REST_METHODS = ["post", "put", "patch"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { + **TEMPLATE_ENTITY_BASE_SCHEMA.schema, vol.Required(CONF_RESOURCE): cv.url, vol.Optional(CONF_STATE_RESOURCE): cv.url, vol.Optional(CONF_HEADERS): {cv.string: cv.template}, @@ -59,7 +64,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All( vol.Lower, vol.In(SUPPORT_REST_METHODS) ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, @@ -76,50 +80,11 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the RESTful switch.""" - body_off = config.get(CONF_BODY_OFF) - body_on = config.get(CONF_BODY_ON) - is_on_template = config.get(CONF_IS_ON_TEMPLATE) - method = config.get(CONF_METHOD) - headers = config.get(CONF_HEADERS) - params = config.get(CONF_PARAMS) - name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - username = config.get(CONF_USERNAME) resource = config.get(CONF_RESOURCE) - state_resource = config.get(CONF_STATE_RESOURCE) or resource - verify_ssl = config.get(CONF_VERIFY_SSL) - - auth = None - if username: - auth = aiohttp.BasicAuth(username, password=config[CONF_PASSWORD]) - - if is_on_template is not None: - is_on_template.hass = hass - if body_on is not None: - body_on.hass = hass - if body_off is not None: - body_off.hass = hass - - template.attach(hass, headers) - template.attach(hass, params) - timeout = config.get(CONF_TIMEOUT) + unique_id = config.get(CONF_UNIQUE_ID) try: - switch = RestSwitch( - name, - device_class, - resource, - state_resource, - method, - headers, - params, - auth, - body_on, - body_off, - is_on_template, - timeout, - verify_ssl, - ) + switch = RestSwitch(hass, config, unique_id) req = await switch.get_device_state(hass) if req.status >= HTTPStatus.BAD_REQUEST: @@ -135,46 +100,53 @@ async def async_setup_platform( _LOGGER.error("No route to resource/endpoint: %s", resource) -class RestSwitch(SwitchEntity): +class RestSwitch(TemplateEntity, SwitchEntity): """Representation of a switch that can be toggled using REST.""" def __init__( self, - name, - device_class, - resource, - state_resource, - method, - headers, - params, - auth, - body_on, - body_off, - is_on_template, - timeout, - verify_ssl, + hass, + config, + unique_id, ): """Initialize the REST switch.""" + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=DEFAULT_NAME, + unique_id=unique_id, + ) + self._state = None - self._name = name - self._resource = resource - self._state_resource = state_resource - self._method = method - self._headers = headers - self._params = params + + auth = None + if username := config.get(CONF_USERNAME): + auth = aiohttp.BasicAuth(username, password=config[CONF_PASSWORD]) + + self._resource = config.get(CONF_RESOURCE) + self._state_resource = config.get(CONF_STATE_RESOURCE) or self._resource + self._method = config.get(CONF_METHOD) + self._headers = config.get(CONF_HEADERS) + self._params = config.get(CONF_PARAMS) self._auth = auth - self._body_on = body_on - self._body_off = body_off - self._is_on_template = is_on_template - self._timeout = timeout - self._verify_ssl = verify_ssl + self._body_on = config.get(CONF_BODY_ON) + self._body_off = config.get(CONF_BODY_OFF) + self._is_on_template = config.get(CONF_IS_ON_TEMPLATE) + self._timeout = config.get(CONF_TIMEOUT) + self._verify_ssl = config.get(CONF_VERIFY_SSL) - self._attr_device_class = device_class + self._attr_device_class = config.get(CONF_DEVICE_CLASS) - @property - def name(self): - """Return the name of the switch.""" - return self._name + if (is_on_template := self._is_on_template) is not None: + is_on_template.hass = hass + if (body_on := self._body_on) is not None: + body_on.hass = hass + if (body_off := self._body_off) is not None: + body_off.hass = hass + + template.attach(hass, self._headers) + template.attach(hass, self._params) @property def is_on(self): diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 563ecedec3d..7bc87de9f46 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -1,4 +1,6 @@ """Support for Rflink devices.""" +from __future__ import annotations + import asyncio from collections import defaultdict import logging @@ -315,8 +317,9 @@ class RflinkDevice(Entity): """ platform = None - _state = None + _state: bool | None = None _available = True + _attr_should_poll = False def __init__( self, @@ -369,11 +372,6 @@ class RflinkDevice(Entity): """Platform specific event handler.""" raise NotImplementedError() - @property - def should_poll(self): - """No polling needed.""" - return False - @property def name(self): """Return a name for the device.""" diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index cd109821582..9492611f439 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -124,7 +125,7 @@ async def async_setup_platform( class RflinkCover(RflinkCommand, CoverEntity, RestoreEntity): """Rflink entity which can switch on/stop/off (eg: cover).""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore RFLink cover state (OPEN/CLOSE).""" await super().async_added_to_hass() if (old_state := await self.async_get_last_state()) is not None: @@ -141,29 +142,24 @@ class RflinkCover(RflinkCommand, CoverEntity, RestoreEntity): self._state = False @property - def should_poll(self): - """No polling available in RFlink cover.""" - return False - - @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" return not self._state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True because covers can be stopped midway.""" return True - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Turn the device close.""" await self._async_handle_command("close_cover") - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Turn the device open.""" await self._async_handle_command("open_cover") - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Turn the device stop.""" await self._async_handle_command("stop_cover") diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index debc12ae4e0..6cef409a736 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -2,7 +2,7 @@ "domain": "rflink", "name": "RFLink", "documentation": "https://www.home-assistant.io/integrations/rflink", - "requirements": ["rflink==0.0.62"], + "requirements": ["rflink==0.0.63"], "codeowners": ["@javicalle"], "iot_class": "assumed_state", "loggers": ["rflink"] diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index ceb34520b07..6bf49beb89a 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import RFXtrx as rfxtrxmod @@ -65,13 +66,13 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): device: rfxtrxmod.RFXtrxDevice, device_id: DeviceTuple, event: rfxtrxmod.RFXtrxEvent = None, - venetian_blind_mode: bool | None = None, + venetian_blind_mode: str | None = None, ) -> None: """Initialize the RFXtrx cover device.""" super().__init__(device, device_id, event) self._venetian_blind_mode = venetian_blind_mode - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore device state.""" await super().async_added_to_hass() @@ -81,7 +82,7 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): self._state = old_state.state == STATE_OPEN @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP @@ -100,11 +101,11 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): return supported_features @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return not self._state - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_up05sec) @@ -115,7 +116,7 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): self._state = True self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_down05sec) @@ -126,27 +127,27 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): self._state = False self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._async_send(self._device.send_stop) self._state = True self.async_write_ha_state() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover up.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_up2sec) elif self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_EU: await self._async_send(self._device.send_up05sec) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover down.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_down2sec) elif self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_EU: await self._async_send(self._device.send_down05sec) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self._async_send(self._device.send_stop) self._state = True diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index cfe1049c888..3439fbba70c 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -2,7 +2,7 @@ "domain": "rfxtrx", "name": "RFXCOM RFXtrx", "documentation": "https://www.home-assistant.io/integrations/rfxtrx", - "requirements": ["pyRFXtrx==0.29.0"], + "requirements": ["pyRFXtrx==0.30.0"], "codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"], "config_flow": true, "iot_class": "local_push", diff --git a/homeassistant/components/rfxtrx/translations/bg.json b/homeassistant/components/rfxtrx/translations/bg.json index c03ec99553e..21a6c42913d 100644 --- a/homeassistant/components/rfxtrx/translations/bg.json +++ b/homeassistant/components/rfxtrx/translations/bg.json @@ -33,8 +33,12 @@ "step": { "prompt_options": { "data": { + "automatic_add": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e\u0442\u043e \u0434\u043e\u0431\u0430\u0432\u044f\u043d\u0435", "protocols": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438" } + }, + "set_device_options": { + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043e\u043f\u0446\u0438\u0438\u0442\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" } } } diff --git a/homeassistant/components/rfxtrx/translations/he.json b/homeassistant/components/rfxtrx/translations/he.json index cabe3734e11..2c2ecd6ccae 100644 --- a/homeassistant/components/rfxtrx/translations/he.json +++ b/homeassistant/components/rfxtrx/translations/he.json @@ -32,6 +32,13 @@ "error": { "already_configured_device": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "prompt_options": { + "data": { + "protocols": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc\u05d9\u05dd" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/sv.json b/homeassistant/components/rfxtrx/translations/sv.json index 6cab9bd0b37..28f5c911c3a 100644 --- a/homeassistant/components/rfxtrx/translations/sv.json +++ b/homeassistant/components/rfxtrx/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, "step": { "setup_network": { "data": { diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py index 405474f5875..722c20336d4 100644 --- a/homeassistant/components/ridwell/config_flow.py +++ b/homeassistant/components/ridwell/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Ridwell integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from aioridwell import async_get_client @@ -80,9 +81,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_USERNAME: self._username, CONF_PASSWORD: self._password}, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/ridwell/translations/sv.json b/homeassistant/components/ridwell/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/ridwell/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 168df4d62e1..da2e447869a 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -10,7 +10,6 @@ import requests from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback @@ -33,6 +32,7 @@ async def async_setup_entry( ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [] for camera in chain( @@ -41,7 +41,7 @@ async def async_setup_entry( if not camera.has_subscription: continue - cams.append(RingCam(config_entry.entry_id, hass.data[DATA_FFMPEG], camera)) + cams.append(RingCam(config_entry.entry_id, ffmpeg_manager, camera)) async_add_entities(cams) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index f3578151acc..3bad03fda10 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Risco alarms.""" +from __future__ import annotations + import logging from homeassistant.components.alarm_control_panel import ( @@ -18,6 +20,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -63,44 +66,46 @@ async def async_setup_entry( class RiscoAlarm(AlarmControlPanelEntity, RiscoEntity): """Representation of a Risco partition.""" + _attr_code_format = CodeFormat.NUMBER + def __init__(self, coordinator, partition_id, code, options): """Init the partition.""" super().__init__(coordinator) self._partition_id = partition_id self._partition = self.coordinator.data.partitions[self._partition_id] self._code = code - self._code_arm_required = options[CONF_CODE_ARM_REQUIRED] + self._attr_code_arm_required = options[CONF_CODE_ARM_REQUIRED] self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] - self._supported_states = 0 + self._attr_supported_features = 0 for state in self._ha_to_risco: - self._supported_states |= STATES_TO_SUPPORTED_FEATURES[state] + self._attr_supported_features |= STATES_TO_SUPPORTED_FEATURES[state] def _get_data_from_coordinator(self): self._partition = self.coordinator.data.partitions[self._partition_id] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "Risco", - } + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=self.name, + manufacturer="Risco", + ) @property - def name(self): + def name(self) -> str: """Return the name of the partition.""" return f"Risco {self._risco.site_name} Partition {self._partition_id}" @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique id for that partition.""" return f"{self._risco.site_uuid}_{self._partition_id}" @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if self._partition.triggered: return STATE_ALARM_TRIGGERED @@ -119,50 +124,35 @@ class RiscoAlarm(AlarmControlPanelEntity, RiscoEntity): return None - @property - def supported_features(self): - """Return the list of supported features.""" - return self._supported_states - - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - - @property - def code_format(self): - """Return one or more digits/characters.""" - return CodeFormat.NUMBER - def _validate_code(self, code): """Validate given code.""" return code == self._code - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if self._code_disarm_required and not self._validate_code(code): _LOGGER.warning("Wrong code entered for disarming") return await self._call_alarm_method("disarm") - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._arm(STATE_ALARM_ARMED_HOME, code) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._arm(STATE_ALARM_ARMED_AWAY, code) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self._arm(STATE_ALARM_ARMED_NIGHT, code) - async def async_alarm_arm_custom_bypass(self, code=None): + async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" await self._arm(STATE_ALARM_ARMED_CUSTOM_BYPASS, code) async def _arm(self, mode, code): - if self._code_arm_required and not self._validate_code(code): + if self.code_arm_required and not self._validate_code(code): _LOGGER.warning("Wrong code entered for %s", mode) return diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index c20aa2af287..e2e139a19e0 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Risco integration.""" +from __future__ import annotations + import logging from pyrisco import CannotConnectError, RiscoAPI, UnauthorizedError @@ -67,7 +69,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> RiscoOptionsFlowHandler: """Define the config flow to handle options.""" return RiscoOptionsFlowHandler(config_entry) @@ -98,7 +102,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class RiscoOptionsFlowHandler(config_entries.OptionsFlow): """Handle a Risco options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry self._data = {**DEFAULT_OPTIONS, **config_entry.options} diff --git a/homeassistant/components/risco/translations/bg.json b/homeassistant/components/risco/translations/bg.json index b9092f75d6c..805d72102aa 100644 --- a/homeassistant/components/risco/translations/bg.json +++ b/homeassistant/components/risco/translations/bg.json @@ -17,5 +17,14 @@ } } } + }, + "options": { + "step": { + "risco_to_ha": { + "data": { + "A": "\u0413\u0440\u0443\u043f\u0430 \u0410" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/risco/translations/sv.json b/homeassistant/components/risco/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/risco/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 1bcadf9aa88..778b70753cb 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -38,8 +38,8 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): """Representation of a diffuser perfume amount number.""" _attr_icon = "mdi:gauge" - _attr_max_value = MAX_PERFUME_AMOUNT - _attr_min_value = MIN_PERFUME_AMOUNT + _attr_native_max_value = MAX_PERFUME_AMOUNT + _attr_native_min_value = MIN_PERFUME_AMOUNT def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator @@ -48,11 +48,11 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): super().__init__(diffuser, coordinator, PERFUME_AMOUNT_SUFFIX) @property - def value(self) -> int: + def native_value(self) -> int: """Return the current perfume amount.""" return self._diffuser.perfume_amount - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the perfume amount.""" if not value.is_integer(): raise ValueError( diff --git a/homeassistant/components/rituals_perfume_genie/translations/bg.json b/homeassistant/components/rituals_perfume_genie/translations/bg.json index cef3726d759..05ef3ed780e 100644 --- a/homeassistant/components/rituals_perfume_genie/translations/bg.json +++ b/homeassistant/components/rituals_perfume_genie/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index f5a68f44ab8..6b3c02a5fab 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -3,15 +3,14 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -import logging from typing import Any, TypeVar from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError from typing_extensions import Concatenate, ParamSpec -from .entity import RokuEntity +from homeassistant.exceptions import HomeAssistantError -_LOGGER = logging.getLogger(__name__) +from .entity import RokuEntity _RokuEntityT = TypeVar("_RokuEntityT", bound=RokuEntity) _P = ParamSpec("_P") @@ -43,14 +42,14 @@ def roku_exception_handler( try: await func(self, *args, **kwargs) except RokuConnectionTimeoutError as error: - if not ignore_timeout and self.available: - _LOGGER.error("Error communicating with API: %s", error) + if not ignore_timeout: + raise HomeAssistantError( + "Timeout communicating with Roku API" + ) from error except RokuConnectionError as error: - if self.available: - _LOGGER.error("Error communicating with API: %s", error) + raise HomeAssistantError("Error communicating with Roku API") from error except RokuError as error: - if self.available: - _LOGGER.error("Invalid response from API: %s", error) + raise HomeAssistantError("Invalid response from Roku API") from error return wrapper diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 3f63a7039c1..05fe0e1b260 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/roku", "requirements": ["rokuecp==0.16.0"], "homekit": { - "models": ["3810X", "4660X", "7820X", "C105X", "C135X"] + "models": ["3820X", "3810X", "4660X", "7820X", "C105X", "C135X"] }, "ssdp": [ { diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 7aee875308b..0a1c51ca38c 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -1,4 +1,5 @@ """Config flow to configure roomba component.""" +from __future__ import annotations import asyncio from functools import partial @@ -78,7 +79,9 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -267,7 +270,7 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/roomba/translations/bg.json b/homeassistant/components/roomba/translations/bg.json index 3da613d9394..948c4afd258 100644 --- a/homeassistant/components/roomba/translations/bg.json +++ b/homeassistant/components/roomba/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, diff --git a/homeassistant/components/rpi_power/translations/sv.json b/homeassistant/components/rpi_power/translations/sv.json new file mode 100644 index 00000000000..0ca2f1f5748 --- /dev/null +++ b/homeassistant/components/rpi_power/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Bara en konfiguration \u00e4r till\u00e5ten." + }, + "step": { + "confirm": { + "description": "Vill du b\u00f6rja med inst\u00e4llning?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/es.json b/homeassistant/components/rtsp_to_webrtc/translations/es.json index 13fe65bfc5b..c74f0b6b34d 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/es.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/es.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "server_failure": "El servidor RTSPtoWebRTC devolvi\u00f3 un error. Consulte los registros para obtener m\u00e1s informaci\u00f3n.", + "server_unreachable": "No se puede comunicar con el servidor RTSPtoWebRTC. Consulte los registros para obtener m\u00e1s informaci\u00f3n.", "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { diff --git a/homeassistant/components/ruckus_unleashed/translations/bg.json b/homeassistant/components/ruckus_unleashed/translations/bg.json index ffb69776060..dcdcdcfc186 100644 --- a/homeassistant/components/ruckus_unleashed/translations/bg.json +++ b/homeassistant/components/ruckus_unleashed/translations/bg.json @@ -1,5 +1,9 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/ruckus_unleashed/translations/sv.json b/homeassistant/components/ruckus_unleashed/translations/sv.json new file mode 100644 index 00000000000..a265d988aaa --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index bfa2482f617..099f0afbae8 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -1,9 +1,9 @@ """Config flow for Samsung TV.""" from __future__ import annotations +from collections.abc import Mapping from functools import partial import socket -from types import MappingProxyType from typing import Any from urllib.parse import urlparse @@ -11,7 +11,7 @@ import getmac from samsungtvws.encrypted.authenticator import SamsungTVEncryptedWSAsyncAuthenticator import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.const import ( CONF_HOST, @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -123,7 +124,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_SSDP_MAIN_TV_AGENT_LOCATION: self._ssdp_main_tv_agent_location, } - def _get_entry_from_bridge(self) -> data_entry_flow.FlowResult: + def _get_entry_from_bridge(self) -> FlowResult: """Get device entry.""" assert self._bridge data = self._base_config_entry() @@ -137,7 +138,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_set_device_unique_id(self, raise_on_progress: bool = True) -> None: """Set device unique_id.""" if not await self._async_get_and_check_device_info(): - raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) + raise AbortFlow(RESULT_NOT_SUPPORTED) await self._async_set_unique_id_from_udn(raise_on_progress) self._async_update_and_abort_for_matching_unique_id() @@ -156,7 +157,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._ssdp_rendering_control_location, self._ssdp_main_tv_agent_location, ): - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") # Now that we have updated the config entry, we can raise # if another one is progressing if raise_on_progress: @@ -184,7 +185,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): result, method, _info = await self._async_get_device_info_and_method() if result not in SUCCESSFUL_RESULTS: LOGGER.debug("No working config found for %s", self._host) - raise data_entry_flow.AbortFlow(result) + raise AbortFlow(result) assert method is not None self._bridge = SamsungTVBridge.get_bridge(self.hass, method, self._host) return @@ -207,7 +208,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Try to get the device info.""" result, _method, info = await self._async_get_device_info_and_method() if result not in SUCCESSFUL_RESULTS: - raise data_entry_flow.AbortFlow(result) + raise AbortFlow(result) if not info: return False dev_info = info.get("device", {}) @@ -216,7 +217,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug( "Host:%s has type: %s which is not supported", self._host, device_type ) - raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) + raise AbortFlow(RESULT_NOT_SUPPORTED) self._model = dev_info.get("modelName") name = dev_info.get("name") self._name = name.replace("[TV] ", "") if name else device_type @@ -230,9 +231,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._mac = mac return True - async def async_step_import( - self, user_input: dict[str, Any] - ) -> data_entry_flow.FlowResult: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle configuration by yaml file.""" # We need to import even if we cannot validate # since the TV may be off at startup @@ -257,13 +256,13 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): socket.gethostbyname, user_input[CONF_HOST] ) except socket.gaierror as err: - raise data_entry_flow.AbortFlow(RESULT_UNKNOWN_HOST) from err + raise AbortFlow(RESULT_UNKNOWN_HOST) from err self._name = user_input.get(CONF_NAME, self._host) or "" self._title = self._name async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: await self._async_set_name_host_from_input(user_input) @@ -281,7 +280,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_pairing( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a pairing by accepting the message on the TV.""" assert self._bridge is not None errors: dict[str, str] = {} @@ -290,7 +289,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if result == RESULT_SUCCESS: return self._get_entry_from_bridge() if result != RESULT_AUTH_MISSING: - raise data_entry_flow.AbortFlow(result) + raise AbortFlow(result) errors = {"base": RESULT_AUTH_MISSING} self.context["title_placeholders"] = {"device": self._title} @@ -303,7 +302,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_encrypted_pairing( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a encrypted pairing.""" assert self._host is not None await self._async_start_encrypted_pairing(self._host) @@ -421,7 +420,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if (entry := self._async_update_existing_matching_entry()) and entry.unique_id: # If we have the unique id and the mac we abort # as we do not need anything else - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") self._async_abort_if_host_already_in_progress() @callback @@ -429,18 +428,16 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context[CONF_HOST] = self._host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == self._host: - raise data_entry_flow.AbortFlow("already_in_progress") + raise AbortFlow("already_in_progress") @callback def _abort_if_manufacturer_is_not_samsung(self) -> None: if not self._manufacturer or not self._manufacturer.lower().startswith( "samsung" ): - raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) + raise AbortFlow(RESULT_NOT_SUPPORTED) - async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "" @@ -485,9 +482,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) self._mac = discovery_info.macaddress @@ -499,7 +494,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) self._mac = format_mac(discovery_info.properties["deviceid"]) @@ -511,7 +506,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_confirm( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: await self._async_create_bridge() @@ -524,24 +519,20 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders={"device": self._title} ) - async def async_step_reauth( - self, data: MappingProxyType[str, Any] - ) -> data_entry_flow.FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - assert self._reauth_entry - data = self._reauth_entry.data - if data.get(CONF_MODEL) and data.get(CONF_NAME): - self._title = f"{data[CONF_NAME]} ({data[CONF_MODEL]})" + if entry_data.get(CONF_MODEL) and entry_data.get(CONF_NAME): + self._title = f"{entry_data[CONF_NAME]} ({entry_data[CONF_MODEL]})" else: - self._title = data.get(CONF_NAME) or data[CONF_HOST] + self._title = entry_data.get(CONF_NAME) or entry_data[CONF_HOST] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Confirm reauth.""" errors = {} assert self._reauth_entry @@ -586,7 +577,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth_confirm_encrypted( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Confirm reauth (encrypted method).""" errors = {} assert self._reauth_entry diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index ce65af7d8bb..9cd068ef409 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -7,7 +7,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.5.0", "wakeonlan==2.0.1", - "async-upnp-client==0.31.1" + "async-upnp-client==0.31.2" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/translations/es.json b/homeassistant/components/samsungtv/translations/es.json index 1c2a81cb7c0..29b2a97027d 100644 --- a/homeassistant/components/samsungtv/translations/es.json +++ b/homeassistant/components/samsungtv/translations/es.json @@ -22,6 +22,9 @@ "encrypted_pairing": { "description": "Introduce el PIN que se muestra en {device}." }, + "pairing": { + "description": "\u00bfQuiere configurar {device}? Si nunca conect\u00f3 Home Assistant antes, deber\u00eda ver una ventana emergente en su televisor solicitando autorizaci\u00f3n." + }, "reauth_confirm": { "description": "Despu\u00e9s de enviarlo, acepte la ventana emergente en {device} solicitando autorizaci\u00f3n dentro de los 30 segundos." }, diff --git a/homeassistant/components/samsungtv/translations/sv.json b/homeassistant/components/samsungtv/translations/sv.json index e9c0803c865..0141800c5c0 100644 --- a/homeassistant/components/samsungtv/translations/sv.json +++ b/homeassistant/components/samsungtv/translations/sv.json @@ -5,7 +5,7 @@ "already_in_progress": "Samsung TV-konfiguration p\u00e5g\u00e5r redan.", "auth_missing": "Home Assistant har inte beh\u00f6righet att ansluta till denna Samsung TV. Kontrollera tv:ns inst\u00e4llningar f\u00f6r att godk\u00e4nna Home Assistant.", "id_missing": "Denna Samsung-enhet har inget serienummer.", - "not_supported": "Denna Samsung TV-enhet st\u00f6ds f\u00f6r n\u00e4rvarande inte.", + "not_supported": "Denna Samsung enhet st\u00f6ds f\u00f6r n\u00e4rvarande inte.", "unknown": "Ov\u00e4ntat fel" }, "flow_title": "{device}", diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 4c036b4be85..79ef4c048b3 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -61,6 +61,9 @@ async def async_setup_platform( class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): """Representation of an AlarmDecoder-based alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_should_poll = False + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -68,13 +71,12 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): def __init__(self, controller, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" - self._name = name - self._state = None + self._attr_name = name self._arm_home_mode = arm_home_mode self._partition_id = partition_id self._satel = controller - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Update alarm status and register callbacks for future updates.""" _LOGGER.debug("Starts listening for panel messages") self._update_alarm_status() @@ -89,8 +91,8 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): """Handle alarm status update.""" state = self._read_alarm_state() _LOGGER.debug("Got status update, current status: %s", state) - if state != self._state: - self._state = state + if state != self._attr_state: + self._attr_state = state self.async_write_ha_state() else: _LOGGER.debug("Ignoring alarm status message, same state") @@ -129,35 +131,15 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): return hass_alarm_status - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def code_format(self): - """Return the regex for code format or None if no code is required.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not code: _LOGGER.debug("Code was empty or None") return - clear_alarm_necessary = self._state == STATE_ALARM_TRIGGERED + clear_alarm_necessary = self._attr_state == STATE_ALARM_TRIGGERED - _LOGGER.debug("Disarming, self._state: %s", self._state) + _LOGGER.debug("Disarming, self._attr_state: %s", self._attr_state) await self._satel.disarm(code, [self._partition_id]) @@ -166,14 +148,14 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): await asyncio.sleep(1) await self._satel.clear_alarm(code, [self._partition_id]) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" _LOGGER.debug("Arming away") if code: await self._satel.arm(code, [self._partition_id]) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" _LOGGER.debug("Arming home") diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py new file mode 100644 index 00000000000..a32e371a487 --- /dev/null +++ b/homeassistant/components/scrape/config_flow.py @@ -0,0 +1,122 @@ +"""Adds config flow for Scrape integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_AUTHENTICATION, + CONF_DEVICE_CLASS, + CONF_HEADERS, + CONF_NAME, + CONF_PASSWORD, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + ObjectSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN + +SCHEMA_SETUP = { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_RESOURCE): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_SELECT): TextSelector(), +} + +SCHEMA_OPT = { + vol.Optional(CONF_ATTRIBUTE): TextSelector(), + vol.Optional(CONF_INDEX, default=0): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_AUTHENTICATION): SelectSelector( + SelectSelectorConfig( + options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_USERNAME): TextSelector(), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_HEADERS): ObjectSelector(), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(), + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in SensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_STATE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), +} + +DATA_SCHEMA = vol.Schema({**SCHEMA_SETUP, **SCHEMA_OPT}) +DATA_SCHEMA_OPT = vol.Schema({**SCHEMA_OPT}) + +CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "user": SchemaFlowFormStep(DATA_SCHEMA), + "import": SchemaFlowFormStep(DATA_SCHEMA), +} +OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep(DATA_SCHEMA_OPT), +} + + +class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Scrape.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return options[CONF_NAME] + + def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: + """Check for duplicate records.""" + data: dict[str, Any] = dict(options) + self._async_abort_entries_match(data) + + +class ScrapeOptionsFlowHandler(SchemaOptionsFlowHandler): + """Handle a config flow for Scrape.""" diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py new file mode 100644 index 00000000000..88eb661d29a --- /dev/null +++ b/homeassistant/components/scrape/const.py @@ -0,0 +1,13 @@ +"""Constants for Scrape integration.""" +from __future__ import annotations + +from homeassistant.const import Platform + +DOMAIN = "scrape" +DEFAULT_NAME = "Web scrape" +DEFAULT_VERIFY_SSL = True + +PLATFORMS = [Platform.SENSOR] + +CONF_SELECT = "select" +CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index e15f7c5ba97..88c9b564b29 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,6 +1,7 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations +from datetime import timedelta import logging from typing import Any @@ -39,6 +40,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=10) + CONF_ATTR = "attribute" CONF_SELECT = "select" CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json new file mode 100644 index 00000000000..f328423f5b6 --- /dev/null +++ b/homeassistant/components/scrape/strings.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "resource": "Resource", + "select": "Select", + "attribute": "Attribute", + "index": "Index", + "value_template": "Value Template", + "unit_of_measurement": "Unit of Measurement", + "device_class": "Device Class", + "state_class": "State Class", + "authentication": "Authentication", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "headers": "Headers" + }, + "data_description": { + "resource": "The URL to the website that contains the value", + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", + "attribute": "Get value of an attribute on the selected tag", + "index": "Defines which of the elements returned by the CSS selector to use", + "value_template": "Defines a template to get the state of the sensor", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "state_class": "The state_class of the sensor", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", + "headers": "Headers to use for the web request" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "[%key:component::scrape::config::step::user::data::name%]", + "resource": "[%key:component::scrape::config::step::user::data::resource%]", + "select": "[%key:component::scrape::config::step::user::data::select%]", + "attribute": "[%key:component::scrape::config::step::user::data::attribute%]", + "index": "[%key:component::scrape::config::step::user::data::index%]", + "value_template": "[%key:component::scrape::config::step::user::data::value_template%]", + "unit_of_measurement": "[%key:component::scrape::config::step::user::data::unit_of_measurement%]", + "device_class": "[%key:component::scrape::config::step::user::data::device_class%]", + "state_class": "[%key:component::scrape::config::step::user::data::state_class%]", + "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", + "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", + "username": "[%key:component::scrape::config::step::user::data::username%]", + "password": "[%key:component::scrape::config::step::user::data::password%]", + "headers": "[%key:component::scrape::config::step::user::data::headers%]" + }, + "data_description": { + "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", + "select": "[%key:component::scrape::config::step::user::data_description::select%]", + "attribute": "[%key:component::scrape::config::step::user::data_description::attribute%]", + "index": "[%key:component::scrape::config::step::user::data_description::index%]", + "value_template": "[%key:component::scrape::config::step::user::data_description::value_template%]", + "device_class": "[%key:component::scrape::config::step::user::data_description::device_class%]", + "state_class": "[%key:component::scrape::config::step::user::data_description::state_class%]", + "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", + "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", + "headers": "[%key:component::scrape::config::step::user::data_description::headers%]" + } + } + } + } +} diff --git a/homeassistant/components/scrape/translations/bg.json b/homeassistant/components/scrape/translations/bg.json new file mode 100644 index 00000000000..f22c3fd3b26 --- /dev/null +++ b/homeassistant/components/scrape/translations/bg.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "step": { + "user": { + "data": { + "attribute": "\u0410\u0442\u0440\u0438\u0431\u0443\u0442", + "authentication": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "index": "\u0418\u043d\u0434\u0435\u043a\u0441", + "name": "\u0418\u043c\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0437\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u043d\u0435", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u0410\u0442\u0440\u0438\u0431\u0443\u0442", + "authentication": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "index": "\u0418\u043d\u0434\u0435\u043a\u0441", + "name": "\u0418\u043c\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0437\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u043d\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/ca.json b/homeassistant/components/scrape/translations/ca.json new file mode 100644 index 00000000000..ff6a0dba168 --- /dev/null +++ b/homeassistant/components/scrape/translations/ca.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "step": { + "user": { + "data": { + "attribute": "Atribut", + "authentication": "Autenticaci\u00f3", + "device_class": "Classe de dispositiu", + "headers": "Cap\u00e7aleres", + "index": "\u00cdndex", + "name": "Nom", + "password": "Contrasenya", + "resource": "Recurs", + "select": "Selecciona", + "state_class": "Classe d'estat", + "unit_of_measurement": "Unitat de mesura", + "username": "Nom d'usuari", + "value_template": "Plantilla de valor", + "verify_ssl": "Verifica el certificat SSL" + }, + "data_description": { + "attribute": "Obt\u00e9 el valor d'un atribut de l'etiqueta seleccionada", + "authentication": "Tipus d'autenticaci\u00f3 HTTP. O b\u00e0sica o 'digest'", + "device_class": "Tipus/classe del sensor per configurar-ne la icona a la interf\u00edcie", + "headers": "Cap\u00e7aleres a utilitzar per a la sol\u00b7licitud web", + "index": "Defineix quins dels elements retornats pel selector CSS utilitzar", + "resource": "URL del lloc web que cont\u00e9 el valor", + "select": "Defineix quina etiqueta s'ha de buscar. Consulta els selectors CSS de Beautifulsoup per m\u00e9s informaci\u00f3", + "state_class": "La state_class del sensor", + "value_template": "Defineix una plantilla per obtenir l'estat del sensor", + "verify_ssl": "Activa/desactiva la verificaci\u00f3 del certificat SSL/TLS, per exemple, si est\u00e0 autosignat" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atribut", + "authentication": "Autenticaci\u00f3", + "device_class": "Classe de dispositiu", + "headers": "Cap\u00e7aleres", + "index": "\u00cdndex", + "name": "Nom", + "password": "Contrasenya", + "resource": "Recurs", + "select": "Selecciona", + "state_class": "Classe d'estat", + "unit_of_measurement": "Unitat de mesura", + "username": "Nom d'usuari", + "value_template": "Plantilla de valor", + "verify_ssl": "Verifica el certificat SSL" + }, + "data_description": { + "attribute": "Obt\u00e9 el valor d'un atribut de l'etiqueta seleccionada", + "authentication": "Tipus d'autenticaci\u00f3 HTTP. O b\u00e0sica o 'digest'", + "device_class": "Tipus/classe del sensor per configurar-ne la icona a la interf\u00edcie", + "headers": "Cap\u00e7aleres a utilitzar per a la sol\u00b7licitud web", + "index": "Defineix quins dels elements retornats pel selector CSS utilitzar", + "resource": "URL del lloc web que cont\u00e9 el valor", + "select": "Defineix quina etiqueta s'ha de buscar. Consulta els selectors CSS de Beautifulsoup per m\u00e9s informaci\u00f3", + "state_class": "La state_class del sensor", + "value_template": "Defineix una plantilla per obtenir l'estat del sensor", + "verify_ssl": "Activa/desactiva la verificaci\u00f3 del certificat SSL/TLS, per exemple, si est\u00e0 autosignat" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/de.json b/homeassistant/components/scrape/translations/de.json new file mode 100644 index 00000000000..d4e2f37f88d --- /dev/null +++ b/homeassistant/components/scrape/translations/de.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "step": { + "user": { + "data": { + "attribute": "Attribut", + "authentication": "Authentifizierung", + "device_class": "Ger\u00e4teklasse", + "headers": "Header", + "index": "Index", + "name": "Name", + "password": "Passwort", + "resource": "Ressource", + "select": "Ausw\u00e4hlen", + "state_class": "Zustandsklasse", + "unit_of_measurement": "Ma\u00dfeinheit", + "username": "Benutzername", + "value_template": "Wertvorlage", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" + }, + "data_description": { + "attribute": "Wert eines Attributs auf dem ausgew\u00e4hlten Tag abrufen", + "authentication": "Typ der HTTP-Authentifizierung. Entweder basic oder digest", + "device_class": "Der Typ/die Klasse des Sensors, um das Symbol im Frontend festzulegen", + "headers": "F\u00fcr die Webanforderung zu verwendende Header", + "index": "Definiert, welche der vom CSS-Selektor zur\u00fcckgegebenen Elemente verwendet werden sollen", + "resource": "Die URL der Website, die den Wert enth\u00e4lt", + "select": "Legt fest, nach welchem Tag gesucht werden soll. Siehe Beautifulsoup CSS-Selektoren f\u00fcr Details", + "state_class": "Die state_class des Sensors", + "value_template": "Definiert eine Vorlage, um den Zustand des Sensors zu ermitteln", + "verify_ssl": "Aktiviert/deaktiviert die \u00dcberpr\u00fcfung des SSL/TLS-Zertifikats, z.B. wenn es selbst signiert ist" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribut", + "authentication": "Authentifizierung", + "device_class": "Ger\u00e4teklasse", + "headers": "Header", + "index": "Index", + "name": "Name", + "password": "Passwort", + "resource": "Ressource", + "select": "Ausw\u00e4hlen", + "state_class": "Zustandsklasse", + "unit_of_measurement": "Ma\u00dfeinheit", + "username": "Benutzername", + "value_template": "Wertvorlage", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" + }, + "data_description": { + "attribute": "Wert eines Attributs auf dem ausgew\u00e4hlten Tag abrufen", + "authentication": "Typ der HTTP-Authentifizierung. Entweder basic oder digest", + "device_class": "Der Typ/die Klasse des Sensors, um das Symbol im Frontend festzulegen", + "headers": "F\u00fcr die Webanforderung zu verwendende Header", + "index": "Definiert, welche der vom CSS-Selektor zur\u00fcckgegebenen Elemente verwendet werden sollen", + "resource": "Die URL der Website, die den Wert enth\u00e4lt", + "select": "Legt fest, nach welchem Tag gesucht werden soll. Siehe Beautifulsoup CSS-Selektoren f\u00fcr Details", + "state_class": "Die state_class des Sensors", + "value_template": "Definiert eine Vorlage, um den Zustand des Sensors zu ermitteln", + "verify_ssl": "Aktiviert/deaktiviert die \u00dcberpr\u00fcfung des SSL/TLS-Zertifikats, z.B. wenn es selbst signiert ist" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/el.json b/homeassistant/components/scrape/translations/el.json new file mode 100644 index 00000000000..8782b2b9f6d --- /dev/null +++ b/homeassistant/components/scrape/translations/el.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + }, + "step": { + "user": { + "data": { + "attribute": "\u03a7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc", + "authentication": "\u0395\u03bb\u03ad\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", + "device_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2", + "index": "\u0394\u03b5\u03af\u03ba\u03c4\u03b7\u03c2", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1", + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "resource": "\u03a0\u03cc\u03c1\u03bf\u03c2", + "select": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae", + "state_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2", + "unit_of_measurement": "\u039c\u03bf\u03bd\u03ac\u03b4\u03b1 \u03bc\u03ad\u03c4\u03c1\u03b7\u03c3\u03b7\u03c2", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7", + "value_template": "\u03a0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03c4\u03b9\u03bc\u03ae\u03c2", + "verify_ssl": "\u0395\u03c0\u03b1\u03bb\u03b7\u03b8\u03b5\u03cd\u03c3\u03c4\u03b5 \u03c4\u03bf \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03cc SSL" + }, + "data_description": { + "attribute": "\u039b\u03ae\u03c8\u03b7 \u03c4\u03b7\u03c2 \u03c4\u03b9\u03bc\u03ae\u03c2 \u03b5\u03bd\u03cc\u03c2 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03bf\u03cd \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03bc\u03ad\u03bd\u03b7 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1", + "authentication": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c4\u03bf\u03c5 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 HTTP. \u0395\u03af\u03c4\u03b5 \u03b2\u03b1\u03c3\u03b9\u03ba\u03cc\u03c2 \u03b5\u03af\u03c4\u03b5 \u03ba\u03c9\u03b4\u03b9\u03ba\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf\u03c2", + "device_class": "\u039f \u03c4\u03cd\u03c0\u03bf\u03c2/\u03ba\u03bb\u03ac\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1 \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03bf\u03c1\u03b9\u03c3\u03bc\u03cc \u03c4\u03bf\u03c5 \u03b5\u03b9\u03ba\u03bf\u03bd\u03b9\u03b4\u03af\u03bf\u03c5 \u03c3\u03c4\u03bf frontend", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2 \u03c0\u03bf\u03c5 \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03b1\u03af\u03c4\u03b7\u03c3\u03b7 \u03b9\u03c3\u03c4\u03bf\u03cd", + "index": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b1\u03c0\u03cc \u03c4\u03b1 \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03b1 \u03c0\u03bf\u03c5 \u03b5\u03c0\u03b9\u03c3\u03c4\u03c1\u03ad\u03c6\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03b1 CSS \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd", + "resource": "\u0397 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03c3\u03c4\u03bf\u03bd \u03b9\u03c3\u03c4\u03cc\u03c4\u03bf\u03c0\u03bf \u03c0\u03bf\u03c5 \u03c0\u03b5\u03c1\u03b9\u03ad\u03c7\u03b5\u03b9 \u03c4\u03b7\u03bd \u03c4\u03b9\u03bc\u03ae", + "select": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1 \u03b8\u03b1 \u03b1\u03bd\u03b1\u03b6\u03b7\u03c4\u03b7\u03b8\u03b5\u03af. \u0395\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03bf\u03c5\u03c2 \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03b5\u03af\u03c2 CSS \u03c4\u03bf\u03c5 Beautifulsoup \u03b3\u03b9\u03b1 \u03bb\u03b5\u03c0\u03c4\u03bf\u03bc\u03ad\u03c1\u03b5\u03b9\u03b5\u03c2", + "state_class": "\u0397 state_class \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1\u03c2", + "value_template": "\u039a\u03b1\u03b8\u03bf\u03c1\u03af\u03b6\u03b5\u03b9 \u03ad\u03bd\u03b1 \u03c0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03c0\u03b1\u03c1\u03b1\u03bb\u03b1\u03b2\u03ae \u03c4\u03b7\u03c2 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1", + "verify_ssl": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af/\u03b1\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03c4\u03b7\u03bd \u03b5\u03c0\u03b1\u03bb\u03ae\u03b8\u03b5\u03c5\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03bf\u03cd SSL/TLS, \u03c0.\u03c7. \u03b1\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b1\u03c5\u03c4\u03bf-\u03c5\u03c0\u03bf\u03b3\u03b5\u03b3\u03c1\u03b1\u03bc\u03bc\u03ad\u03bd\u03bf." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u03a7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc", + "authentication": "\u0395\u03bb\u03ad\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", + "device_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2", + "index": "\u0394\u03b5\u03af\u03ba\u03c4\u03b7\u03c2", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1", + "password": "\u039a\u03b5\u03bd\u03cc", + "resource": "\u03a0\u03cc\u03c1\u03bf\u03c2", + "select": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae", + "state_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2", + "unit_of_measurement": "\u039c\u03bf\u03bd\u03ac\u03b4\u03b1 \u03bc\u03ad\u03c4\u03c1\u03b7\u03c3\u03b7\u03c2", + "username": "\u039a\u03b5\u03bd\u03cc", + "value_template": "\u03a0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03c4\u03b9\u03bc\u03ae\u03c2", + "verify_ssl": "\u0395\u03c0\u03b1\u03bb\u03b7\u03b8\u03b5\u03cd\u03c3\u03c4\u03b5 \u03c4\u03bf \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03cc SSL" + }, + "data_description": { + "attribute": "\u039b\u03ae\u03c8\u03b7 \u03c4\u03b7\u03c2 \u03c4\u03b9\u03bc\u03ae\u03c2 \u03b5\u03bd\u03cc\u03c2 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03bf\u03cd \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03bc\u03ad\u03bd\u03b7 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1", + "authentication": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c4\u03bf\u03c5 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 HTTP. \u0395\u03af\u03c4\u03b5 \u03b2\u03b1\u03c3\u03b9\u03ba\u03cc\u03c2 \u03b5\u03af\u03c4\u03b5 \u03ba\u03c9\u03b4\u03b9\u03ba\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf\u03c2", + "device_class": "\u039f \u03c4\u03cd\u03c0\u03bf\u03c2/\u03ba\u03bb\u03ac\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1 \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03bf\u03c1\u03b9\u03c3\u03bc\u03cc \u03c4\u03bf\u03c5 \u03b5\u03b9\u03ba\u03bf\u03bd\u03b9\u03b4\u03af\u03bf\u03c5 \u03c3\u03c4\u03bf frontend", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2 \u03c0\u03bf\u03c5 \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03b1\u03af\u03c4\u03b7\u03c3\u03b7 \u03b9\u03c3\u03c4\u03bf\u03cd", + "index": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b1\u03c0\u03cc \u03c4\u03b1 \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03b1 \u03c0\u03bf\u03c5 \u03b5\u03c0\u03b9\u03c3\u03c4\u03c1\u03ad\u03c6\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03b1 CSS \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd", + "resource": "\u0397 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03c3\u03c4\u03bf\u03bd \u03b9\u03c3\u03c4\u03cc\u03c4\u03bf\u03c0\u03bf \u03c0\u03bf\u03c5 \u03c0\u03b5\u03c1\u03b9\u03ad\u03c7\u03b5\u03b9 \u03c4\u03b7\u03bd \u03c4\u03b9\u03bc\u03ae", + "select": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1 \u03b8\u03b1 \u03b1\u03bd\u03b1\u03b6\u03b7\u03c4\u03b7\u03b8\u03b5\u03af. \u0395\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03bf\u03c5\u03c2 \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03b5\u03af\u03c2 CSS \u03c4\u03bf\u03c5 Beautifulsoup \u03b3\u03b9\u03b1 \u03bb\u03b5\u03c0\u03c4\u03bf\u03bc\u03ad\u03c1\u03b5\u03b9\u03b5\u03c2", + "state_class": "\u0397 state_class \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1", + "value_template": "\u039a\u03b1\u03b8\u03bf\u03c1\u03af\u03b6\u03b5\u03b9 \u03ad\u03bd\u03b1 \u03c0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03c0\u03b1\u03c1\u03b1\u03bb\u03b1\u03b2\u03ae \u03c4\u03b7\u03c2 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1", + "verify_ssl": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af/\u03b1\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03c4\u03b7\u03bd \u03b5\u03c0\u03b1\u03bb\u03ae\u03b8\u03b5\u03c5\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03bf\u03cd SSL/TLS, \u03c0.\u03c7. \u03b1\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b1\u03c5\u03c4\u03bf-\u03c5\u03c0\u03bf\u03b3\u03b5\u03b3\u03c1\u03b1\u03bc\u03bc\u03ad\u03bd\u03bf." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/en.json b/homeassistant/components/scrape/translations/en.json new file mode 100644 index 00000000000..20831f5251a --- /dev/null +++ b/homeassistant/components/scrape/translations/en.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "step": { + "user": { + "data": { + "attribute": "Attribute", + "authentication": "Authentication", + "device_class": "Device Class", + "headers": "Headers", + "index": "Index", + "name": "Name", + "password": "Password", + "resource": "Resource", + "select": "Select", + "state_class": "State Class", + "unit_of_measurement": "Unit of Measurement", + "username": "Username", + "value_template": "Value Template", + "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "attribute": "Get value of an attribute on the selected tag", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "headers": "Headers to use for the web request", + "index": "Defines which of the elements returned by the CSS selector to use", + "resource": "The URL to the website that contains the value", + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", + "state_class": "The state_class of the sensor", + "value_template": "Defines a template to get the state of the sensor", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribute", + "authentication": "Authentication", + "device_class": "Device Class", + "headers": "Headers", + "index": "Index", + "name": "Name", + "password": "Password", + "resource": "Resource", + "select": "Select", + "state_class": "State Class", + "unit_of_measurement": "Unit of Measurement", + "username": "Username", + "value_template": "Value Template", + "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "attribute": "Get value of an attribute on the selected tag", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "headers": "Headers to use for the web request", + "index": "Defines which of the elements returned by the CSS selector to use", + "resource": "The URL to the website that contains the value", + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", + "state_class": "The state_class of the sensor", + "value_template": "Defines a template to get the state of the sensor", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/es.json b/homeassistant/components/scrape/translations/es.json new file mode 100644 index 00000000000..660d687344c --- /dev/null +++ b/homeassistant/components/scrape/translations/es.json @@ -0,0 +1,15 @@ +{ + "options": { + "step": { + "init": { + "data_description": { + "resource": "La URL del sitio web que contiene el valor.", + "select": "Define qu\u00e9 etiqueta buscar. Consulte los selectores de CSS de Beautifulsoup para obtener m\u00e1s informaci\u00f3n.", + "state_class": "El state_class del sensor", + "value_template": "Define una plantilla para obtener el estado del sensor", + "verify_ssl": "Habilita/deshabilita la verificaci\u00f3n del certificado SSL/TLS, por ejemplo, si est\u00e1 autofirmado" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/et.json b/homeassistant/components/scrape/translations/et.json new file mode 100644 index 00000000000..14daf835af8 --- /dev/null +++ b/homeassistant/components/scrape/translations/et.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud" + }, + "step": { + "user": { + "data": { + "attribute": "Atribuut", + "authentication": "Tuvastamine", + "device_class": "Seadme klass", + "headers": "P\u00e4ised", + "index": "Indeks", + "name": "Nimi", + "password": "Salas\u00f5na", + "resource": "Resurss", + "select": "Vali", + "state_class": "Oleku klass", + "unit_of_measurement": "M\u00f5\u00f5t\u00fchik", + "username": "Kasutajanimi", + "value_template": "V\u00e4\u00e4rtuse mall", + "verify_ssl": "Kontrolli SSL serti" + }, + "data_description": { + "attribute": "Hangi valitud sildi atribuudi v\u00e4\u00e4rtus", + "authentication": "HTTP-autentimise t\u00fc\u00fcp. Kas basic v\u00f5i digest", + "device_class": "Anduri t\u00fc\u00fcp/klass ikooni seadmiseks kasutajaliideses", + "headers": "Veebip\u00e4ringu jaoks kasutatavad p\u00e4ised", + "index": "M\u00e4\u00e4rab, milliseid CSS selektoriga tagastatud elemente kasutada.", + "resource": "V\u00e4\u00e4rtust sisaldava veebisaidi URL", + "select": "M\u00e4\u00e4rab, millist silti otsida. Lisateavet leiad Beautifulsoup CSS-i valijatest", + "state_class": "Anduri oleku klass", + "value_template": "M\u00e4\u00e4rab malli anduri oleku saamiseks", + "verify_ssl": "Lubab/keelab SSL/TLS-sertifikaadi kontrollimise, n\u00e4iteks kui see on ise allkirjastatud" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atribuut", + "authentication": "Tuvastamine", + "device_class": "Seadme klss", + "headers": "P\u00e4ised", + "index": "Indeks", + "name": "", + "password": "Salas\u00f5na", + "resource": "Resurss", + "select": "Vali", + "state_class": "Oleku klass", + "unit_of_measurement": "M\u00f5\u00f5t\u00fchik", + "username": "", + "value_template": "V\u00e4\u00e4rtuse mall", + "verify_ssl": "" + }, + "data_description": { + "attribute": "Hangi valitud elemendi atribuudi v\u00e4\u00e4rtus", + "authentication": "HTTP kasutaja tuvastamise meetod; algeline v\u00f5i muu", + "device_class": "Kasutajaliidesesse lisatava anduri ikooni t\u00fc\u00fcp/klass", + "headers": "Veebip\u00e4ringus kasutatav p\u00e4is", + "index": "M\u00e4\u00e4rab milline element tagastatakse kasutatava CSS valiku alusel", + "resource": "Veebilehe URL ei sisalda soovitud v\u00e4\u00e4rtusi", + "select": "M\u00e4\u00e4rab otsitava v\u00f5tmes\u00f5na. Vaata Beatifulsoup CSS valimeid", + "state_class": "Anduri olekuklass", + "value_template": "M\u00e4\u00e4rab anduri oleku saamiseks vajaliku malli", + "verify_ssl": "Lubab v\u00f5i keelab SSL/TLS serdi tuvastamise n\u00e4iteks juhul kui sert on ise allkirjastatud" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/fr.json b/homeassistant/components/scrape/translations/fr.json new file mode 100644 index 00000000000..f68ce5808b7 --- /dev/null +++ b/homeassistant/components/scrape/translations/fr.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "step": { + "user": { + "data": { + "attribute": "Attribut", + "authentication": "Authentification", + "device_class": "Classe d'appareil", + "headers": "En-t\u00eates", + "index": "Index", + "name": "Nom", + "password": "Mot de passe", + "resource": "Ressource", + "select": "S\u00e9lectionner", + "state_class": "Classe d'\u00e9tat", + "unit_of_measurement": "Unit\u00e9 de mesure", + "username": "Nom d'utilisateur", + "value_template": "Mod\u00e8le de valeur", + "verify_ssl": "V\u00e9rifier le certificat SSL" + }, + "data_description": { + "attribute": "Obtenir la valeur d'un attribut de la balise s\u00e9lectionn\u00e9e", + "authentication": "M\u00e9thode d'authentification HTTP. \u00ab\u00a0basic\u00a0\u00bb ou \u00ab\u00a0digest\u00a0\u00bb", + "device_class": "Le type (la classe) du capteur qui d\u00e9finira l'ic\u00f4ne dans l'interface", + "headers": "Les en-t\u00eates \u00e0 utiliser pour la requ\u00eate Web", + "index": "D\u00e9finit l'\u00e9l\u00e9ment \u00e0 utiliser parmi ceux renvoy\u00e9s par le s\u00e9lecteur CSS", + "resource": "L'URL du site web qui contient la valeur", + "select": "D\u00e9finit la balise \u00e0 rechercher. Consultez les s\u00e9lecteurs CSS de Beautifulsoup pour plus de d\u00e9tails", + "state_class": "La state_class du capteur", + "value_template": "D\u00e9finit un mod\u00e8le pour obtenir l'\u00e9tat du capteur", + "verify_ssl": "Active ou d\u00e9sactive la v\u00e9rification du certificat SSL/TLS, par exemple s'il est auto-sign\u00e9" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribut", + "authentication": "Authentification", + "device_class": "Classe d'appareil", + "headers": "En-t\u00eates", + "index": "Index", + "name": "Nom", + "password": "Mot de passe", + "resource": "Ressource", + "select": "S\u00e9lectionner", + "state_class": "Classe d'\u00e9tat", + "unit_of_measurement": "Unit\u00e9 de mesure", + "username": "Nom d'utilisateur", + "value_template": "Mod\u00e8le de valeur", + "verify_ssl": "V\u00e9rifier le certificat SSL" + }, + "data_description": { + "attribute": "Obtenir la valeur d'un attribut de la balise s\u00e9lectionn\u00e9e", + "authentication": "M\u00e9thode d'authentification HTTP. \u00ab\u00a0basic\u00a0\u00bb ou \u00ab\u00a0digest\u00a0\u00bb", + "device_class": "Le type (la classe) du capteur qui d\u00e9finira l'ic\u00f4ne dans l'interface", + "headers": "Les en-t\u00eates \u00e0 utiliser pour la requ\u00eate Web", + "index": "D\u00e9finit l'\u00e9l\u00e9ment \u00e0 utiliser parmi ceux renvoy\u00e9s par le s\u00e9lecteur CSS", + "resource": "L'URL du site web qui contient la valeur", + "select": "D\u00e9finit la balise \u00e0 rechercher. Consultez les s\u00e9lecteurs CSS de Beautifulsoup pour plus de d\u00e9tails", + "state_class": "La state_class du capteur", + "value_template": "D\u00e9finit un mod\u00e8le pour obtenir l'\u00e9tat du capteur", + "verify_ssl": "Active ou d\u00e9sactive la v\u00e9rification du certificat SSL/TLS, par exemple s'il est auto-sign\u00e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/he.json b/homeassistant/components/scrape/translations/he.json new file mode 100644 index 00000000000..463ce9035f4 --- /dev/null +++ b/homeassistant/components/scrape/translations/he.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "\u05e9\u05dd", + "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/hu.json b/homeassistant/components/scrape/translations/hu.json new file mode 100644 index 00000000000..7af59751b98 --- /dev/null +++ b/homeassistant/components/scrape/translations/hu.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "attribute": "Attrib\u00fatum", + "authentication": "Hiteles\u00edt\u00e9s", + "device_class": "Eszk\u00f6zoszt\u00e1ly", + "headers": "Fejl\u00e9cek", + "index": "Index", + "name": "Elnevez\u00e9s", + "password": "Jelsz\u00f3", + "resource": "Er\u0151forr\u00e1s", + "select": "Kiv\u00e1laszt\u00e1s", + "state_class": "\u00c1llapotoszt\u00e1ly", + "unit_of_measurement": "M\u00e9rt\u00e9kegys\u00e9g", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "value_template": "\u00c9rt\u00e9ksablon", + "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" + }, + "data_description": { + "attribute": "Egy attrib\u00fatum \u00e9rt\u00e9k\u00e9nek lek\u00e9r\u00e9se a kiv\u00e1lasztott c\u00edmk\u00e9n", + "authentication": "A HTTP-hiteles\u00edt\u00e9s t\u00edpusa. Basic vagy digest", + "device_class": "Az \u00e9rz\u00e9kel\u0151 t\u00edpusa/oszt\u00e1lya az ikonnak a kezl\u0151fel\u00fcleten val\u00f3 be\u00e1ll\u00edt\u00e1s\u00e1hoz", + "headers": "A webes k\u00e9r\u00e9shez haszn\u00e1land\u00f3 fejl\u00e9cek", + "index": "Meghat\u00e1rozza, hogy a CSS-v\u00e1laszt\u00f3 \u00e1ltal visszaadott elemek k\u00f6z\u00fcl melyiket haszn\u00e1lja.", + "resource": "Az \u00e9rt\u00e9ket tartalmaz\u00f3 weboldal URL c\u00edme", + "select": "Meghat\u00e1rozza, hogy milyen c\u00edmk\u00e9t keressen. N\u00e9zze meg a Beautifulsoup CSS szelektorokat a r\u00e9szletek\u00e9rt", + "state_class": "Az \u00e9rz\u00e9kel\u0151 \u00e1llapot oszt\u00e1lya", + "value_template": "Meghat\u00e1roz egy sablont az \u00e9rz\u00e9kel\u0151 \u00e1llapot\u00e1nak lek\u00e9rdez\u00e9s\u00e9re.", + "verify_ssl": "Az SSL/TLS tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9s\u00e9nek enged\u00e9lyez\u00e9se/letilt\u00e1sa, p\u00e9ld\u00e1ul ha saj\u00e1t al\u00e1\u00edr\u00e1s\u00fa." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attrib\u00fatum", + "authentication": "Hiteles\u00edt\u00e9s", + "device_class": "Eszk\u00f6zoszt\u00e1ly", + "headers": "Fejl\u00e9cek", + "index": "Index", + "name": "Elnevez\u00e9s", + "password": "Jelsz\u00f3", + "resource": "Er\u0151forr\u00e1s", + "select": "Kiv\u00e1laszt\u00e1s", + "state_class": "\u00c1llapotoszt\u00e1ly", + "unit_of_measurement": "M\u00e9rt\u00e9kegys\u00e9g", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "value_template": "\u00c9rt\u00e9ksablon", + "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" + }, + "data_description": { + "attribute": "Egy attrib\u00fatum \u00e9rt\u00e9k\u00e9nek lek\u00e9r\u00e9se a kiv\u00e1lasztott c\u00edmk\u00e9n", + "authentication": "A HTTP-hiteles\u00edt\u00e9s t\u00edpusa. Basic vagy digest", + "device_class": "Az \u00e9rz\u00e9kel\u0151 t\u00edpusa/oszt\u00e1lya az ikonnak a kezl\u0151fel\u00fcleten val\u00f3 be\u00e1ll\u00edt\u00e1s\u00e1hoz", + "headers": "A webes k\u00e9r\u00e9shez haszn\u00e1land\u00f3 fejl\u00e9cek", + "index": "Meghat\u00e1rozza, hogy a CSS-v\u00e1laszt\u00f3 \u00e1ltal visszaadott elemek k\u00f6z\u00fcl melyiket haszn\u00e1lja.", + "resource": "Az \u00e9rt\u00e9ket tartalmaz\u00f3 weboldal URL c\u00edme", + "select": "Meghat\u00e1rozza, hogy milyen c\u00edmk\u00e9t keressen. N\u00e9zze meg a Beautifulsoup CSS szelektorokat a r\u00e9szletek\u00e9rt", + "state_class": "Az \u00e9rz\u00e9kel\u0151 \u00e1llapot oszt\u00e1lya", + "value_template": "Meghat\u00e1roz egy sablont az \u00e9rz\u00e9kel\u0151 \u00e1llapot\u00e1nak lek\u00e9rdez\u00e9s\u00e9re.", + "verify_ssl": "Az SSL/TLS tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9s\u00e9nek enged\u00e9lyez\u00e9se/letilt\u00e1sa, p\u00e9ld\u00e1ul ha saj\u00e1t al\u00e1\u00edr\u00e1s\u00fa." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/id.json b/homeassistant/components/scrape/translations/id.json new file mode 100644 index 00000000000..e7761f73a1f --- /dev/null +++ b/homeassistant/components/scrape/translations/id.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "step": { + "user": { + "data": { + "attribute": "Atribut", + "authentication": "Autentikasi", + "device_class": "Kelas Perangkat", + "headers": "Header", + "index": "Indeks", + "name": "Nama", + "password": "Kata Sandi", + "resource": "Sumber Daya", + "select": "Pilihan", + "state_class": "Kelas Status", + "unit_of_measurement": "Satuan Pengukuran", + "username": "Nama Pengguna", + "value_template": "Nilai Templat", + "verify_ssl": "Verifikasi sertifikat SSL" + }, + "data_description": { + "attribute": "Dapatkan nilai atribut pada tag yang dipilih", + "authentication": "Jenis autentikasi HTTP. Salah satu dari basic atau digest", + "device_class": "Jenis/kelas sensor untuk mengatur ikon di antarmuka", + "headers": "Header yang digunakan untuk permintaan web", + "index": "Menentukan elemen mana yang dikembalikan oleh selektor CSS untuk digunakan", + "resource": "URL ke situs web yang mengandung nilai", + "select": "Menentukan tag yang harus dicari. Periksa selektor CSS Beautifulsoup untuk melihat detailnya", + "state_class": "Nilai state_class dari sensor", + "value_template": "Mendefinisikan templat untuk mendapatkan status sensor", + "verify_ssl": "Mengaktifkan/menonaktifkan verifikasi sertifikat SSL/TLS, misalnya jika sertifikat ditandatangani sendiri" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atribut", + "authentication": "Autentikasi", + "device_class": "Kelas Perangkat", + "headers": "Header", + "index": "Indeks", + "name": "Nama", + "password": "Kata Sandi", + "resource": "Sumber Daya", + "select": "Pilihan", + "state_class": "Kelas Status", + "unit_of_measurement": "Satuan Pengukuran", + "username": "Nama Pengguna", + "value_template": "Nilai Templat", + "verify_ssl": "Verifikasi sertifikat SSL" + }, + "data_description": { + "attribute": "Dapatkan nilai atribut pada tag yang dipilih", + "authentication": "Jenis autentikasi HTTP. Salah satu dari basic atau digest", + "device_class": "Jenis/kelas sensor untuk mengatur ikon di antarmuka", + "headers": "Header yang digunakan untuk permintaan web", + "index": "Menentukan elemen mana yang dikembalikan oleh selektor CSS untuk digunakan", + "resource": "URL ke situs web yang mengandung nilai", + "select": "Menentukan tag yang harus dicari. Periksa selektor CSS Beautifulsoup untuk melihat detailnya", + "state_class": "Nilai state_class dari sensor", + "value_template": "Mendefinisikan templat untuk mendapatkan status sensor", + "verify_ssl": "Mengaktifkan/menonaktifkan verifikasi sertifikat SSL/TLS, misalnya jika sertifikat ditandatangani sendiri" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/it.json b/homeassistant/components/scrape/translations/it.json new file mode 100644 index 00000000000..e64ee3022d8 --- /dev/null +++ b/homeassistant/components/scrape/translations/it.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "step": { + "user": { + "data": { + "attribute": "Attributo", + "authentication": "Autenticazione", + "device_class": "Classe del dispositivo", + "headers": "Intestazioni", + "index": "Indice", + "name": "Nome", + "password": "Password", + "resource": "Risorsa", + "select": "Seleziona", + "state_class": "Classe di Stato", + "unit_of_measurement": "Unit\u00e0 di misura", + "username": "Nome utente", + "value_template": "Modello di valore", + "verify_ssl": "Verifica il certificato SSL" + }, + "data_description": { + "attribute": "Ottieni il valore di un attributo sull'etichetta selezionata", + "authentication": "Tipo di autenticazione HTTP. basic o digest", + "device_class": "Il tipo/classe del sensore per impostare l'icona nel frontend", + "headers": "Intestazioni da utilizzare per la richiesta web", + "index": "Definisce quale degli elementi restituiti dal selettore CSS utilizzare", + "resource": "L'URL del sito Web che contiene il valore", + "select": "Definisce quale etichetta cercare. Controlla i selettori CSS di Beautifulsoup per i dettagli", + "state_class": "La state_class del sensore", + "value_template": "Definisce un modello per ottenere lo stato del sensore", + "verify_ssl": "Abilita/disabilita la verifica del certificato SSL/TLS, ad esempio se \u00e8 autofirmato" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attributo", + "authentication": "Autenticazione", + "device_class": "Classe del dispositivo", + "headers": "Intestazioni", + "index": "Indice", + "name": "Nome", + "password": "Password", + "resource": "Risorsa", + "select": "Seleziona", + "state_class": "Classe di Stato", + "unit_of_measurement": "Unit\u00e0 di misura", + "username": "Nome utente", + "value_template": "Modello di valore", + "verify_ssl": "Verifica il certificato SSL" + }, + "data_description": { + "attribute": "Ottieni il valore di un attributo sull'etichetta selezionata", + "authentication": "Tipo di autenticazione HTTP. basic o digest", + "device_class": "Il tipo/classe del sensore per impostare l'icona nel frontend", + "headers": "Intestazioni da utilizzare per la richiesta web", + "index": "Definisce quale degli elementi restituiti dal selettore CSS utilizzare", + "resource": "L'URL del sito Web che contiene il valore", + "select": "Definisce quale etichetta cercare. Controlla i selettori CSS di Beautifulsoup per i dettagli", + "state_class": "La state_class del sensore", + "value_template": "Definisce un modello per ottenere lo stato del sensore", + "verify_ssl": "Abilita/disabilita la verifica del certificato SSL/TLS, ad esempio se \u00e8 autofirmato" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/ja.json b/homeassistant/components/scrape/translations/ja.json new file mode 100644 index 00000000000..554a9d2c37b --- /dev/null +++ b/homeassistant/components/scrape/translations/ja.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "attribute": "\u5c5e\u6027", + "authentication": "\u8a8d\u8a3c", + "device_class": "\u30c7\u30d0\u30a4\u30b9\u30af\u30e9\u30b9", + "headers": "\u30d8\u30c3\u30c0\u30fc", + "index": "\u30a4\u30f3\u30c7\u30c3\u30af\u30b9", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "resource": "\u30ea\u30bd\u30fc\u30b9", + "select": "\u9078\u629e", + "state_class": "\u72b6\u614b\u30af\u30e9\u30b9(State Class)", + "unit_of_measurement": "\u6e2c\u5b9a\u306e\u5358\u4f4d", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "value_template": "\u5024\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "data_description": { + "attribute": "\u9078\u629e\u3057\u305f\u30bf\u30b0\u306e\u5c5e\u6027\u306e\u5024\u3092\u53d6\u5f97\u3059\u308b", + "authentication": "HTTP\u8a8d\u8a3c\u306e\u7a2e\u985e\u3002\u30d9\u30fc\u30b7\u30c3\u30af\u307e\u305f\u306f\u30c0\u30a4\u30b8\u30a7\u30b9\u30c8\u306e\u3069\u3061\u3089\u304b", + "device_class": "\u30d5\u30ed\u30f3\u30c8\u30a8\u30f3\u30c9\u306b\u30a2\u30a4\u30b3\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u30bb\u30f3\u30b5\u30fc\u306e\u30bf\u30a4\u30d7/\u30af\u30e9\u30b9", + "headers": "Web\u30ea\u30af\u30a8\u30b9\u30c8\u306b\u4f7f\u7528\u3059\u308b\u30d8\u30c3\u30c0\u30fc", + "index": "CSS\u30bb\u30ec\u30af\u30bf\u304c\u8fd4\u3059\u8981\u7d20\u306e\u3046\u3061\u3001\u3069\u306e\u8981\u7d20\u3092\u4f7f\u7528\u3059\u308b\u304b\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "resource": "\u5024\u3092\u542b\u3080\u30a6\u30a7\u30d6\u30b5\u30a4\u30c8\u306eURL", + "select": "\u691c\u7d22\u3059\u308b\u30bf\u30b0\u3092\u5b9a\u7fa9\u3057\u307e\u3059\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001Beautifulsoup CSS selectors\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)", + "value_template": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u3092\u53d6\u5f97\u3059\u308b\u305f\u3081\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "verify_ssl": "SSL/TLS\u8a3c\u660e\u66f8\u306e\u691c\u8a3c\u3092\u6709\u52b9/\u7121\u52b9\u306b\u3057\u307e\u3059\u3002(\u81ea\u5df1\u7f72\u540d\u306e\u5834\u5408\u306a\u3069)" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u5c5e\u6027", + "authentication": "\u8a8d\u8a3c", + "device_class": "\u30c7\u30d0\u30a4\u30b9\u30af\u30e9\u30b9", + "headers": "\u30d8\u30c3\u30c0\u30fc", + "index": "\u30a4\u30f3\u30c7\u30c3\u30af\u30b9", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "resource": "\u30ea\u30bd\u30fc\u30b9", + "select": "\u9078\u629e", + "state_class": "\u72b6\u614b\u30af\u30e9\u30b9(State Class)", + "unit_of_measurement": "\u6e2c\u5b9a\u306e\u5358\u4f4d", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "value_template": "\u5024\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "data_description": { + "attribute": "\u9078\u629e\u3057\u305f\u30bf\u30b0\u306e\u5c5e\u6027\u306e\u5024\u3092\u53d6\u5f97\u3059\u308b", + "authentication": "HTTP\u8a8d\u8a3c\u306e\u7a2e\u985e\u3002\u30d9\u30fc\u30b7\u30c3\u30af\u307e\u305f\u306f\u30c0\u30a4\u30b8\u30a7\u30b9\u30c8\u306e\u3069\u3061\u3089\u304b", + "device_class": "\u30d5\u30ed\u30f3\u30c8\u30a8\u30f3\u30c9\u306b\u30a2\u30a4\u30b3\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u30bb\u30f3\u30b5\u30fc\u306e\u30bf\u30a4\u30d7/\u30af\u30e9\u30b9", + "headers": "Web\u30ea\u30af\u30a8\u30b9\u30c8\u306b\u4f7f\u7528\u3059\u308b\u30d8\u30c3\u30c0\u30fc", + "index": "CSS\u30bb\u30ec\u30af\u30bf\u304c\u8fd4\u3059\u8981\u7d20\u306e\u3046\u3061\u3001\u3069\u306e\u8981\u7d20\u3092\u4f7f\u7528\u3059\u308b\u304b\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "resource": "\u5024\u3092\u542b\u3080\u30a6\u30a7\u30d6\u30b5\u30a4\u30c8\u306eURL", + "select": "\u691c\u7d22\u3059\u308b\u30bf\u30b0\u3092\u5b9a\u7fa9\u3057\u307e\u3059\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001Beautifulsoup CSS selectors\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)", + "value_template": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u3092\u53d6\u5f97\u3059\u308b\u305f\u3081\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "verify_ssl": "SSL/TLS\u8a3c\u660e\u66f8\u306e\u691c\u8a3c\u3092\u6709\u52b9/\u7121\u52b9\u306b\u3057\u307e\u3059\u3002(\u81ea\u5df1\u7f72\u540d\u306e\u5834\u5408\u306a\u3069)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/nl.json b/homeassistant/components/scrape/translations/nl.json new file mode 100644 index 00000000000..90e85d34677 --- /dev/null +++ b/homeassistant/components/scrape/translations/nl.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "step": { + "user": { + "data": { + "attribute": "Attribuut", + "authentication": "Authenticatie", + "device_class": "Apparaatklasse", + "headers": "Headers", + "index": "Index", + "name": "Naam", + "password": "Wachtwoord", + "resource": "Bron", + "select": "Selecteer", + "state_class": "Staatklasse", + "unit_of_measurement": "Meeteenheid", + "username": "Gebruikersnaam", + "value_template": "Waardetemplate", + "verify_ssl": "SSL-certificaat verifi\u00ebren" + }, + "data_description": { + "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", + "authentication": "Type van de HTTP-authenticatie. Ofwel basic of digest", + "device_class": "Het type/klasse van de sensor om het pictogram in de frontend in te stellen", + "headers": "Headers om te gebruiken voor het webverzoek", + "index": "Definieert welke van de door de CSS-selector geretourneerde elementen moeten worden gebruikt", + "resource": "De URL naar de website die de waarde bevat", + "select": "Definieert naar welke tag moet worden gezocht. Controleer Beautifulsoup CSS-selectors voor details", + "state_class": "De state_class van de sensor", + "value_template": "Definieert een sjabloon om de status van de sensor te krijgen", + "verify_ssl": "Activeert/de-activeert verificatie van SSL/TLS certificaat, als voorbeeld of het is zelf-getekend" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribuut", + "authentication": "Authenticatie", + "device_class": "Apparaatklasse", + "headers": "Headers", + "index": "Index", + "name": "Naam", + "password": "Wachtwoord", + "resource": "Bron", + "select": "Selecteer", + "state_class": "Staatklasse", + "unit_of_measurement": "Meeteenheid", + "username": "Gebruikersnaam", + "value_template": "Waardetemplate", + "verify_ssl": "SSL-certificaat verifi\u00ebren" + }, + "data_description": { + "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", + "authentication": "Type van de HTTP-authenticatie. Ofwel basic of digest", + "device_class": "Het type/klasse van de sensor om het pictogram in de frontend in te stellen", + "headers": "Headers om te gebruiken voor het webverzoek", + "index": "Definieert welke van de door de CSS-selector geretourneerde elementen moeten worden gebruikt", + "resource": "De URL naar de website die de waarde bevat", + "select": "Definieert naar welke tag moet worden gezocht. Controleer Beautifulsoup CSS-selectors voor details", + "state_class": "De state_class van de sensor", + "value_template": "Definieert een sjabloon om de status van de sensor te krijgen", + "verify_ssl": "Activeert/de-activeert verificatie van SSL/TLS certificaat, als voorbeeld of het is zelf-getekend" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/no.json b/homeassistant/components/scrape/translations/no.json new file mode 100644 index 00000000000..6738c8a630a --- /dev/null +++ b/homeassistant/components/scrape/translations/no.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "step": { + "user": { + "data": { + "attribute": "Attributt", + "authentication": "Godkjenning", + "device_class": "Enhetsklasse", + "headers": "Overskrifter", + "index": "Indeks", + "name": "Navn", + "password": "Passord", + "resource": "Ressurs", + "select": "Velg", + "state_class": "Statsklasse", + "unit_of_measurement": "M\u00e5leenhet", + "username": "Brukernavn", + "value_template": "Verdimal", + "verify_ssl": "Verifisere SSL-sertifikat" + }, + "data_description": { + "attribute": "F\u00e5 verdien av et attributt p\u00e5 den valgte taggen", + "authentication": "Type HTTP-godkjenning. Enten grunnleggende eller ufullstendig", + "device_class": "Typen/klassen til sensoren for \u00e5 angi ikonet i frontend", + "headers": "Overskrifter som skal brukes for nettforesp\u00f8rselen", + "index": "Definerer hvilke av elementene som returneres av CSS-velgeren som skal brukes", + "resource": "URL-en til nettstedet som inneholder verdien", + "select": "Definerer hvilken tag som skal s\u00f8kes etter. Sjekk Beautifulsoup CSS-velgere for detaljer", + "state_class": "Sensorens state_class", + "value_template": "Definerer en mal for \u00e5 f\u00e5 tilstanden til sensoren", + "verify_ssl": "Aktiverer/deaktiverer verifisering av SSL/TLS-sertifikat, for eksempel hvis det er selvsignert" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attributt", + "authentication": "Godkjenning", + "device_class": "Enhetsklasse", + "headers": "Overskrifter", + "index": "Indeks", + "name": "Navn", + "password": "Passord", + "resource": "Ressurs", + "select": "Velg", + "state_class": "Statsklasse", + "unit_of_measurement": "M\u00e5leenhet", + "username": "Brukernavn", + "value_template": "Verdimal", + "verify_ssl": "Verifisere SSL-sertifikat" + }, + "data_description": { + "attribute": "F\u00e5 verdien av et attributt p\u00e5 den valgte taggen", + "authentication": "Type HTTP-godkjenning. Enten grunnleggende eller ufullstendig", + "device_class": "Typen/klassen til sensoren for \u00e5 angi ikonet i frontend", + "headers": "Overskrifter som skal brukes for nettforesp\u00f8rselen", + "index": "Definerer hvilke av elementene som returneres av CSS-velgeren som skal brukes", + "resource": "URL-en til nettstedet som inneholder verdien", + "select": "Definerer hvilken tag som skal s\u00f8kes etter. Sjekk Beautifulsoup CSS-velgere for detaljer", + "state_class": "Sensorens state_class", + "value_template": "Definerer en mal for \u00e5 f\u00e5 tilstanden til sensoren", + "verify_ssl": "Aktiverer/deaktiverer verifisering av SSL/TLS-sertifikat, for eksempel hvis det er selvsignert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/pl.json b/homeassistant/components/scrape/translations/pl.json new file mode 100644 index 00000000000..67b2a3db685 --- /dev/null +++ b/homeassistant/components/scrape/translations/pl.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane" + }, + "step": { + "user": { + "data": { + "attribute": "Atrybut", + "authentication": "Uwierzytelnianie", + "device_class": "Klasa urz\u0105dzenia", + "headers": "Nag\u0142\u00f3wki", + "index": "Indeks", + "name": "Nazwa", + "password": "Has\u0142o", + "resource": "Zas\u00f3b", + "select": "Wybierz", + "state_class": "Klasa stanu", + "unit_of_measurement": "Jednostka miary", + "username": "Nazwa u\u017cytkownika", + "value_template": "Szablon warto\u015bci", + "verify_ssl": "Weryfikacja certyfikatu SSL" + }, + "data_description": { + "attribute": "Pobierz warto\u015b\u0107 atrybutu w wybranym tagu", + "authentication": "Typ uwierzytelniania HTTP. Podstawowy lub digest.", + "device_class": "Typ/klasa sensora do ustawienia ikony w interfejsie u\u017cytkownika", + "headers": "Nag\u0142\u00f3wki do u\u017cycia w \u017c\u0105daniu internetowym", + "index": "Okre\u015bla, kt\u00f3rego z element\u00f3w zwracanych przez selektor CSS nale\u017cy u\u017cy\u0107", + "resource": "Adres URL strony internetowej zawieraj\u0105cej t\u0105 warto\u015b\u0107", + "select": "Okre\u015bla jakiego taga szuka\u0107. Sprawd\u017a selektory CSS Beautifulsoup, aby uzyska\u0107 szczeg\u00f3\u0142owe informacje.", + "state_class": "state_class sensora", + "value_template": "Szablon, kt\u00f3ry pozwala uzyska\u0107 stan czujnika", + "verify_ssl": "W\u0142\u0105cza/wy\u0142\u0105cza weryfikacj\u0119 certyfikatu SSL/TLS, na przyk\u0142ad, je\u015bli jest on samopodpisany." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atrybut", + "authentication": "Uwierzytelnianie", + "device_class": "Klasa urz\u0105dzenia", + "headers": "Nag\u0142\u00f3wki", + "index": "Indeks", + "name": "Nazwa", + "password": "Has\u0142o", + "resource": "Zas\u00f3b", + "select": "Wybierz", + "state_class": "Klasa stanu", + "unit_of_measurement": "Jednostka miary", + "username": "Nazwa u\u017cytkownika", + "value_template": "Szablon warto\u015bci", + "verify_ssl": "Weryfikacja certyfikatu SSL" + }, + "data_description": { + "attribute": "Pobierz warto\u015b\u0107 atrybutu w wybranym tagu", + "authentication": "Typ uwierzytelniania HTTP. Podstawowy lub digest.", + "device_class": "Typ/klasa sensora do ustawienia ikony w interfejsie u\u017cytkownika", + "headers": "Nag\u0142\u00f3wki do u\u017cycia w \u017c\u0105daniu internetowym", + "index": "Okre\u015bla, kt\u00f3rego z element\u00f3w zwracanych przez selektor CSS nale\u017cy u\u017cy\u0107", + "resource": "Adres URL strony internetowej zawieraj\u0105cej t\u0105 warto\u015b\u0107", + "select": "Okre\u015bla jakiego taga szuka\u0107. Sprawd\u017a selektory CSS Beautifulsoup, aby uzyska\u0107 szczeg\u00f3\u0142owe informacje.", + "state_class": "state_class sensora", + "value_template": "Szablon, kt\u00f3ry pozwala uzyska\u0107 stan czujnika", + "verify_ssl": "W\u0142\u0105cza/wy\u0142\u0105cza weryfikacj\u0119 certyfikatu SSL/TLS, na przyk\u0142ad, je\u015bli jest on samopodpisany." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/pt-BR.json b/homeassistant/components/scrape/translations/pt-BR.json new file mode 100644 index 00000000000..9876157182e --- /dev/null +++ b/homeassistant/components/scrape/translations/pt-BR.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "A conta j\u00e1 foi configurada" + }, + "step": { + "user": { + "data": { + "attribute": "Atributo", + "authentication": "Autentica\u00e7\u00e3o", + "device_class": "Classe do dispositivo", + "headers": "Cabe\u00e7alhos", + "index": "\u00cdndice", + "name": "Nome", + "password": "Senha", + "resource": "Recurso", + "select": "Selecionar", + "state_class": "Classe de estado", + "unit_of_measurement": "Unidade de medida", + "username": "Usu\u00e1rio", + "value_template": "Modelo de valor", + "verify_ssl": "Verifique o certificado SSL" + }, + "data_description": { + "attribute": "Obter valor de um atributo na tag selecionada", + "authentication": "Tipo de autentica\u00e7\u00e3o HTTP. b\u00e1sica ou digerida", + "device_class": "O tipo/classe do sensor para definir o \u00edcone na frontend", + "headers": "Cabe\u00e7alhos a serem usados para a solicita\u00e7\u00e3o da web", + "index": "Define qual dos elementos retornados pelo seletor CSS usar", + "resource": "A URL para o site que cont\u00e9m o valor", + "select": "Define qual tag pesquisar. Verifique os seletores CSS da Beautiful Soup para obter detalhes", + "state_class": "O classe de estado do sensor", + "value_template": "Define um modelo para obter o estado do sensor", + "verify_ssl": "Ativa/desativa a verifica\u00e7\u00e3o do certificado SSL/TLS, por exemplo, se for autoassinado" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atributo", + "authentication": "Autentica\u00e7\u00e3o", + "device_class": "Classe do dispositivo", + "headers": "Cabe\u00e7alhos", + "index": "\u00cdndice", + "name": "Nome", + "password": "Senha", + "resource": "Recurso", + "select": "Selecionar", + "state_class": "Classe de estado", + "unit_of_measurement": "Unidade de medida", + "username": "Usu\u00e1rio", + "value_template": "Modelo de valor", + "verify_ssl": "Verificar SSL" + }, + "data_description": { + "attribute": "Obter valor de um atributo na tag selecionada", + "authentication": "Tipo de autentica\u00e7\u00e3o HTTP. b\u00e1sica ou digerida", + "device_class": "O tipo/classe do sensor para definir o \u00edcone na frontend", + "headers": "Cabe\u00e7alhos a serem usados para a solicita\u00e7\u00e3o da web", + "index": "Define qual dos elementos retornados pelo seletor CSS usar", + "resource": "A URL para o site que cont\u00e9m o valor", + "select": "Define qual tag pesquisar. Verifique os seletores CSS da Beautiful Soup para obter detalhes", + "state_class": "O classe de estado do sensor", + "value_template": "Define um modelo para obter o estado do sensor", + "verify_ssl": "Ativa/desativa a verifica\u00e7\u00e3o do certificado SSL/TLS, por exemplo, se for autoassinado" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/sv.json b/homeassistant/components/scrape/translations/sv.json new file mode 100644 index 00000000000..c70f08008dc --- /dev/null +++ b/homeassistant/components/scrape/translations/sv.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn", + "verify_ssl": "Verifiera SSL-certifikat" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/tr.json b/homeassistant/components/scrape/translations/tr.json new file mode 100644 index 00000000000..954ce1ad052 --- /dev/null +++ b/homeassistant/components/scrape/translations/tr.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "attribute": "\u00d6znitelik", + "authentication": "Kimlik do\u011frulama", + "device_class": "Cihaz S\u0131n\u0131f\u0131", + "headers": "Ba\u015fl\u0131klar", + "index": "Dizin", + "name": "Ad", + "password": "Parola", + "resource": "Kaynak", + "select": "Se\u00e7", + "state_class": "Durum S\u0131n\u0131f\u0131", + "unit_of_measurement": "\u00d6l\u00e7\u00fc Birimi", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "value_template": "De\u011fer \u015eablonu", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "data_description": { + "attribute": "Se\u00e7ilen etikette bir \u00f6zelli\u011fin de\u011ferini al\u0131n", + "authentication": "HTTP kimlik do\u011frulamas\u0131n\u0131n t\u00fcr\u00fc. Temel veya basit", + "device_class": "\u00d6nu\u00e7taki simgeyi ayarlamak i\u00e7in sens\u00f6r\u00fcn t\u00fcr\u00fc/s\u0131n\u0131f\u0131", + "headers": "Web iste\u011fi i\u00e7in kullan\u0131lacak ba\u015fl\u0131klar", + "index": "CSS se\u00e7ici taraf\u0131ndan d\u00f6nd\u00fcr\u00fclen \u00f6\u011felerden hangisinin kullan\u0131laca\u011f\u0131n\u0131 tan\u0131mlar", + "resource": "De\u011feri i\u00e7eren web sitesinin URL'si", + "select": "Hangi etiketin aranaca\u011f\u0131n\u0131 tan\u0131mlar. Ayr\u0131nt\u0131lar i\u00e7in Beautifulsoup CSS se\u00e7icilerini kontrol edin", + "state_class": "Sens\u00f6r\u00fcn state_class", + "value_template": "Sens\u00f6r\u00fcn durumunu almak i\u00e7in bir \u015fablon tan\u0131mlar", + "verify_ssl": "\u00d6rne\u011fin, kendinden imzal\u0131ysa, SSL/TLS sertifikas\u0131n\u0131n do\u011frulanmas\u0131n\u0131 etkinle\u015ftirir/devre d\u0131\u015f\u0131 b\u0131rak\u0131r" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u00d6znitelik", + "authentication": "Kimlik do\u011frulama", + "device_class": "Cihaz S\u0131n\u0131f\u0131", + "headers": "Ba\u015fl\u0131klar", + "index": "Dizin", + "name": "Ad", + "password": "Parola", + "resource": "Kaynak", + "select": "Se\u00e7", + "state_class": "Durum S\u0131n\u0131f\u0131", + "unit_of_measurement": "\u00d6l\u00e7\u00fc Birimi", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "value_template": "De\u011fer \u015eablonu", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "data_description": { + "attribute": "Se\u00e7ilen etikette bir \u00f6zelli\u011fin de\u011ferini al\u0131n", + "authentication": "HTTP kimlik do\u011frulamas\u0131n\u0131n t\u00fcr\u00fc. Temel veya basit", + "device_class": "\u00d6nu\u00e7taki simgeyi ayarlamak i\u00e7in sens\u00f6r\u00fcn t\u00fcr\u00fc/s\u0131n\u0131f\u0131", + "headers": "Web iste\u011fi i\u00e7in kullan\u0131lacak ba\u015fl\u0131klar", + "index": "CSS se\u00e7ici taraf\u0131ndan d\u00f6nd\u00fcr\u00fclen \u00f6\u011felerden hangisinin kullan\u0131laca\u011f\u0131n\u0131 tan\u0131mlar", + "resource": "De\u011feri i\u00e7eren web sitesinin URL'si", + "select": "Hangi etiketin aranaca\u011f\u0131n\u0131 tan\u0131mlar. Ayr\u0131nt\u0131lar i\u00e7in Beautifulsoup CSS se\u00e7icilerini kontrol edin", + "state_class": "Sens\u00f6r\u00fcn state_class", + "value_template": "Sens\u00f6r\u00fcn durumunu almak i\u00e7in bir \u015fablon tan\u0131mlar", + "verify_ssl": "\u00d6rne\u011fin, kendinden imzal\u0131ysa, SSL/TLS sertifikas\u0131n\u0131n do\u011frulanmas\u0131n\u0131 etkinle\u015ftirir/devre d\u0131\u015f\u0131 b\u0131rak\u0131r" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/zh-Hant.json b/homeassistant/components/scrape/translations/zh-Hant.json new file mode 100644 index 00000000000..499ca44d334 --- /dev/null +++ b/homeassistant/components/scrape/translations/zh-Hant.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "attribute": "\u5c6c\u6027", + "authentication": "\u9a57\u8b49", + "device_class": "\u88dd\u7f6e\u985e\u5225", + "headers": "Headers", + "index": "\u6307\u6578", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "resource": "\u4f86\u6e90", + "select": "\u9078\u64c7", + "state_class": "\u72c0\u614b\u985e\u5225", + "unit_of_measurement": "\u6e2c\u91cf\u55ae\u4f4d", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "value_template": "\u6578\u503c\u6a21\u677f", + "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" + }, + "data_description": { + "attribute": "\u7372\u53d6\u6240\u9078\u6a19\u7c64\u5c6c\u6027\u6578\u503c", + "authentication": "HTTP \u9a57\u8b49\u985e\u578b\u3002\u57fa\u672c\u6216\u6458\u8981", + "device_class": "\u65bc Frontend \u4e2d\u8a2d\u5b9a\u4e4b\u50b3\u611f\u5668\u985e\u578b/\u985e\u5225\u5716\u793a", + "headers": "\u7528\u65bc Web \u8acb\u6c42\u4e4b Headers", + "index": "\u5b9a\u7fa9\u4f7f\u7528 CSS selector \u56de\u8986\u5143\u7d20", + "resource": "\u5305\u542b\u6578\u503c\u7684\u7db2\u7ad9 URL", + "select": "\u5b9a\u7fa9\u8981\u7d22\u7684\u6a19\u7c64\u3002\u53c3\u95b1 Beautifulsoup CSS selector \u4ee5\u7372\u5f97\u8a73\u7d30\u8cc7\u8a0a", + "state_class": "\u611f\u6e2c\u5668 state_class", + "value_template": "\u5b9a\u7fa9\u6a21\u677f\u4ee5\u53d6\u5f97\u611f\u6e2c\u5668\u72c0\u614b", + "verify_ssl": "\u958b\u555f/\u95dc\u9589 SSL/TLS \u9a57\u8b49\u8a8d\u8b49\uff0c\u4f8b\u5982\u81ea\u7c3d\u7ae0\u6191\u8b49" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u5c6c\u6027", + "authentication": "\u9a57\u8b49", + "device_class": "\u88dd\u7f6e\u985e\u5225", + "headers": "Headers", + "index": "\u6307\u6578", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "resource": "\u4f86\u6e90", + "select": "\u9078\u64c7", + "state_class": "\u72c0\u614b\u985e\u5225", + "unit_of_measurement": "\u6e2c\u91cf\u55ae\u4f4d", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "value_template": "\u6578\u503c\u6a21\u677f", + "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" + }, + "data_description": { + "attribute": "\u7372\u53d6\u6240\u9078\u6a19\u7c64\u5c6c\u6027\u6578\u503c", + "authentication": "HTTP \u9a57\u8b49\u985e\u578b\u3002\u57fa\u672c\u6216\u6458\u8981", + "device_class": "\u65bc Frontend \u4e2d\u8a2d\u5b9a\u4e4b\u50b3\u611f\u5668\u985e\u578b/\u985e\u5225\u5716\u793a", + "headers": "\u7528\u65bc Web \u8acb\u6c42\u4e4b Headers", + "index": "\u5b9a\u7fa9\u4f7f\u7528 CSS selector \u56de\u8986\u5143\u7d20", + "resource": "\u5305\u542b\u6578\u503c\u7684\u7db2\u7ad9 URL", + "select": "\u5b9a\u7fa9\u8981\u7d22\u7684\u6a19\u7c64\u3002\u53c3\u95b1 Beautifulsoup CSS selector \u4ee5\u7372\u5f97\u8a73\u7d30\u8cc7\u8a0a", + "state_class": "\u611f\u6e2c\u5668 state_class", + "value_template": "\u5b9a\u7fa9\u6a21\u677f\u4ee5\u53d6\u5f97\u611f\u6e2c\u5668\u72c0\u614b", + "verify_ssl": "\u958b\u555f/\u95dc\u9589 SSL/TLS \u9a57\u8b49\u8a8d\u8b49\uff0c\u4f8b\u5982\u81ea\u7c3d\u7ae0\u6191\u8b49" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 1aeedfb421d..2b845d453df 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -1,4 +1,6 @@ """Config flow for ScreenLogic.""" +from __future__ import annotations + import logging from screenlogicpy import ScreenLogicError, discovery @@ -69,7 +71,9 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> ScreenLogicOptionsFlowHandler: """Get the options flow for ScreenLogic.""" return ScreenLogicOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 253b5c9641e..75dee907cc1 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -46,16 +46,16 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): """Initialize of the entity.""" super().__init__(coordinator, data_key, enabled) self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) - self._attr_max_value = SCG.LIMIT_FOR_BODY[self._body_type] + self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type] self._attr_name = f"{self.gateway_name} {self.sensor['name']}" - self._attr_unit_of_measurement = self.sensor["unit"] + self._attr_native_unit_of_measurement = self.sensor["unit"] @property - def value(self) -> float: + def native_value(self) -> float: """Return the current value.""" return self.sensor["value"] - async def async_set_value(self, value: float) -> None: + 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. diff --git a/homeassistant/components/screenlogic/translations/bg.json b/homeassistant/components/screenlogic/translations/bg.json index 1c611d756fd..b8fccb94a47 100644 --- a/homeassistant/components/screenlogic/translations/bg.json +++ b/homeassistant/components/screenlogic/translations/bg.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "gateway_entry": { "data": { diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index ddfb59c6fba..4aa08cae3bd 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from scsgate.tasks import ( HaltRollerShutterTask, @@ -71,29 +72,29 @@ class SCSGateCover(CoverEntity): return self._scs_id @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed.""" return False @property - def name(self): + def name(self) -> str: """Return the name of the cover.""" return self._name @property - def is_closed(self): + def is_closed(self) -> None: """Return if the cover is closed.""" return None - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Move the cover.""" self._scsgate.append_task(RaiseRollerShutterTask(target=self._scs_id)) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" self._scsgate.append_task(LowerRollerShutterTask(target=self._scs_id)) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._scsgate.append_task(HaltRollerShutterTask(target=self._scs_id)) diff --git a/homeassistant/components/season/translations/es.json b/homeassistant/components/season/translations/es.json new file mode 100644 index 00000000000..0d22ef0bc1c --- /dev/null +++ b/homeassistant/components/season/translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "El servicio ya est\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "type": "Definici\u00f3n del tipo de estaci\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/season/translations/sv.json b/homeassistant/components/season/translations/sv.json new file mode 100644 index 00000000000..c0b662beebe --- /dev/null +++ b/homeassistant/components/season/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/select/translations/sv.json b/homeassistant/components/select/translations/sv.json new file mode 100644 index 00000000000..d388cb6c622 --- /dev/null +++ b/homeassistant/components/select/translations/sv.json @@ -0,0 +1,7 @@ +{ + "device_automation": { + "condition_type": { + "selected_option": "Nuvarande {entity_name} markerad option" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py index 8769d4cb83f..0690344ccf1 100644 --- a/homeassistant/components/sense/config_flow.py +++ b/homeassistant/components/sense/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Sense integration.""" +from collections.abc import Mapping import logging +from typing import Any from sense_energy import ( ASyncSenseable, @@ -10,6 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT, DOMAIN, SENSE_CONNECT_EXCEPTIONS @@ -120,10 +123,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._auth_data = dict(data) - return await self.async_step_reauth_validate(data) + self._auth_data = dict(entry_data) + return await self.async_step_reauth_validate(entry_data) async def async_step_reauth_validate(self, user_input=None): """Handle reauth and validation.""" diff --git a/homeassistant/components/sense/translations/bg.json b/homeassistant/components/sense/translations/bg.json index d42d6dba5c1..2be0802eef9 100644 --- a/homeassistant/components/sense/translations/bg.json +++ b/homeassistant/components/sense/translations/bg.json @@ -3,6 +3,9 @@ "abort": { "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "reauth_validate": { "data": { diff --git a/homeassistant/components/sense/translations/es.json b/homeassistant/components/sense/translations/es.json index 5621988c60b..3593b08f17c 100644 --- a/homeassistant/components/sense/translations/es.json +++ b/homeassistant/components/sense/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" }, "error": { "cannot_connect": "No se pudo conectar", @@ -13,7 +14,8 @@ "data": { "password": "Contrase\u00f1a" }, - "description": "La integraci\u00f3n Sense debe volver a autenticar la cuenta {email}." + "description": "La integraci\u00f3n Sense debe volver a autenticar la cuenta {email}.", + "title": "Reautenticar la integraci\u00f3n" }, "user": { "data": { @@ -26,7 +28,8 @@ "validation": { "data": { "code": "C\u00f3digo de verificaci\u00f3n" - } + }, + "title": "Detecci\u00f3n de autenticaci\u00f3n multifactor" } } } diff --git a/homeassistant/components/senseme/translations/es.json b/homeassistant/components/senseme/translations/es.json index fefa2c8d70c..a10e4422a82 100644 --- a/homeassistant/components/senseme/translations/es.json +++ b/homeassistant/components/senseme/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "Fallo en la conexi\u00f3n" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 27e551a51c8..ed280aab4fe 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -20,6 +21,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class MotionBaseEntityDescriptionMixin: @@ -49,6 +52,13 @@ class SensiboDeviceBinarySensorEntityDescription( """Describes Sensibo Motion sensor entity.""" +FILTER_CLEAN_REQUIRED_DESCRIPTION = SensiboDeviceBinarySensorEntityDescription( + key="filter_clean", + device_class=BinarySensorDeviceClass.PROBLEM, + name="Filter Clean Required", + value_fn=lambda data: data.filter_clean, +) + MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = ( SensiboMotionBinarySensorEntityDescription( key="alive", @@ -74,7 +84,7 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = ( ), ) -DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( +MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( SensiboDeviceBinarySensorEntityDescription( key="room_occupied", device_class=BinarySensorDeviceClass.MOTION, @@ -84,6 +94,46 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( ), ) +DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( + FILTER_CLEAN_REQUIRED_DESCRIPTION, +) + +PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( + SensiboDeviceBinarySensorEntityDescription( + key="pure_ac_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with AC", + icon="mdi:connection", + value_fn=lambda data: data.pure_ac_integration, + ), + SensiboDeviceBinarySensorEntityDescription( + key="pure_geo_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with Presence", + icon="mdi:connection", + value_fn=lambda data: data.pure_geo_integration, + ), + SensiboDeviceBinarySensorEntityDescription( + key="pure_measure_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with Indoor Air Quality", + icon="mdi:connection", + value_fn=lambda data: data.pure_measure_integration, + ), + SensiboDeviceBinarySensorEntityDescription( + key="pure_prime_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with Outdoor Air Quality", + icon="mdi:connection", + value_fn=lambda data: data.pure_prime_integration, + ), + FILTER_CLEAN_REQUIRED_DESCRIPTION, +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -93,18 +143,33 @@ async def async_setup_entry( coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + + for device_id, device_data in coordinator.data.parsed.items(): + if device_data.motion_sensors: + entities.extend( + SensiboMotionSensor( + coordinator, device_id, sensor_id, sensor_data, description + ) + for sensor_id, sensor_data in device_data.motion_sensors.items() + for description in MOTION_SENSOR_TYPES + ) entities.extend( - SensiboMotionSensor(coordinator, device_id, sensor_id, sensor_data, description) + SensiboDeviceSensor(coordinator, device_id, description) + for description in MOTION_DEVICE_SENSOR_TYPES for device_id, device_data in coordinator.data.parsed.items() - for sensor_id, sensor_data in device_data.motion_sensors.items() - for description in MOTION_SENSOR_TYPES if device_data.motion_sensors ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for description in PURE_SENSOR_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.model == "pure" + ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) for description in DEVICE_SENSOR_TYPES for device_id, device_data in coordinator.data.parsed.items() - if getattr(device_data, description.key) is not None + if device_data.model != "pure" ) async_add_entities(entities) @@ -140,6 +205,8 @@ class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" + if TYPE_CHECKING: + assert self.sensor_data return self.entity_description.value_fn(self.sensor_data) diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py new file mode 100644 index 00000000000..97ae6321f7e --- /dev/null +++ b/homeassistant/components/sensibo/button.py @@ -0,0 +1,68 @@ +"""Button platform for Sensibo integration.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SensiboDataUpdateCoordinator +from .entity import SensiboDeviceBaseEntity + +PARALLEL_UPDATES = 0 + +DEVICE_BUTTON_TYPES: ButtonEntityDescription = ButtonEntityDescription( + key="reset_filter", + name="Reset Filter", + icon="mdi:air-filter", + entity_category=EntityCategory.CONFIG, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sensibo binary sensor platform.""" + + coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[SensiboDeviceButton] = [] + + entities.extend( + SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) + for device_id, device_data in coordinator.data.parsed.items() + ) + + async_add_entities(entities) + + +class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity): + """Representation of a Sensibo Device Binary Sensor.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: SensiboDataUpdateCoordinator, + device_id: str, + entity_description: ButtonEntityDescription, + ) -> None: + """Initiate Sensibo Device Button.""" + super().__init__( + coordinator, + device_id, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}-{entity_description.key}" + self._attr_name = f"{self.device_data.name} {entity_description.name}" + + async def async_press(self) -> None: + """Press the button.""" + result = await self.async_send_command("reset_filter") + if result["status"] == "success": + await self.coordinator.async_request_refresh() + return + raise HomeAssistantError(f"Could not set calibration for device {self.name}") diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index c1e690cd28a..b4af38ab69c 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -1,6 +1,9 @@ """Support for Sensibo wifi-enabled home thermostats.""" from __future__ import annotations +from bisect import bisect_left +from typing import TYPE_CHECKING, Any + import voluptuous as vol from homeassistant.components.climate import ClimateEntity @@ -15,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.temperature import convert as convert_temperature @@ -24,6 +27,19 @@ from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity SERVICE_ASSUME_STATE = "assume_state" +SERVICE_ENABLE_TIMER = "enable_timer" +ATTR_MINUTES = "minutes" +SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost" +SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" + +ATTR_AC_INTEGRATION = "ac_integration" +ATTR_GEO_INTEGRATION = "geo_integration" +ATTR_INDOOR_INTEGRATION = "indoor_integration" +ATTR_OUTDOOR_INTEGRATION = "outdoor_integration" +ATTR_SENSITIVITY = "sensitivity" +BOOST_INCLUSIVE = "boost_inclusive" + +PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { "fanLevel": ClimateEntityFeature.FAN_MODE, @@ -51,6 +67,14 @@ AC_STATE_TO_DATA = { } +def _find_valid_target_temp(target: int, valid_targets: list[int]) -> int: + if target <= valid_targets[0]: + return valid_targets[0] + if target >= valid_targets[-1]: + return valid_targets[-1] + return valid_targets[bisect_left(valid_targets, target)] + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -73,6 +97,24 @@ async def async_setup_entry( }, "async_assume_state", ) + platform.async_register_entity_service( + SERVICE_ENABLE_TIMER, + { + vol.Required(ATTR_MINUTES): cv.positive_int, + }, + "async_enable_timer", + ) + platform.async_register_entity_service( + SERVICE_ENABLE_PURE_BOOST, + { + vol.Required(ATTR_AC_INTEGRATION): bool, + vol.Required(ATTR_GEO_INTEGRATION): bool, + vol.Required(ATTR_INDOOR_INTEGRATION): bool, + vol.Required(ATTR_OUTDOOR_INTEGRATION): bool, + vol.Required(ATTR_SENSITIVITY): vol.In(["Normal", "Sensitive"]), + }, + "async_enable_pure_boost", + ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @@ -107,70 +149,87 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return hvac operation.""" - if self.device_data.device_on: + if self.device_data.device_on and self.device_data.hvac_mode: return SENSIBO_TO_HA[self.device_data.hvac_mode] return HVACMode.OFF @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" - return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] + hvac_modes = [] + if TYPE_CHECKING: + assert self.device_data.hvac_modes + for mode in self.device_data.hvac_modes: + hvac_modes.append(SENSIBO_TO_HA[mode]) + return hvac_modes if hvac_modes else [HVACMode.OFF] @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return convert_temperature( - self.device_data.temp, - TEMP_CELSIUS, - self.temperature_unit, - ) + if self.device_data.temp: + return convert_temperature( + self.device_data.temp, + TEMP_CELSIUS, + self.temperature_unit, + ) + return None @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self.device_data.target_temp + target_temp: int | None = self.device_data.target_temp + return target_temp @property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - return self.device_data.temp_step + target_temp_step: int = self.device_data.temp_step + return target_temp_step @property def fan_mode(self) -> str | None: """Return the fan setting.""" - return self.device_data.fan_mode + fan_mode: str | None = self.device_data.fan_mode + return fan_mode @property def fan_modes(self) -> list[str] | None: """Return the list of available fan modes.""" - return self.device_data.fan_modes + if self.device_data.fan_modes: + return self.device_data.fan_modes + return None @property def swing_mode(self) -> str | None: """Return the swing setting.""" - return self.device_data.swing_mode + swing_mode: str | None = self.device_data.swing_mode + return swing_mode @property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - return self.device_data.swing_modes + if self.device_data.swing_modes: + return self.device_data.swing_modes + return None @property def min_temp(self) -> float: """Return the minimum temperature.""" - return self.device_data.temp_list[0] + min_temp: int = self.device_data.temp_list[0] + return min_temp @property def max_temp(self) -> float: """Return the maximum temperature.""" - return self.device_data.temp_list[-1] + max_temp: int = self.device_data.temp_list[-1] + return max_temp @property def available(self) -> bool: """Return True if entity is available.""" return self.device_data.available and super().available - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if "targetTemperature" not in self.device_data.active_features: raise HomeAssistantError( @@ -183,20 +242,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): if temperature == self.target_temperature: return - if temperature not in self.device_data.temp_list: - # Requested temperature is not supported. - if temperature > self.device_data.temp_list[-1]: - temperature = self.device_data.temp_list[-1] - - elif temperature < self.device_data.temp_list[0]: - temperature = self.device_data.temp_list[0] - - else: - raise ValueError( - f"Target temperature has to be one off {str(self.device_data.temp_list)}" - ) - - await self._async_set_ac_state_property("targetTemperature", int(temperature)) + new_temp = _find_valid_target_temp(temperature, self.device_data.temp_list) + await self._async_set_ac_state_property("targetTemperature", new_temp) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" @@ -255,7 +302,47 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): f"Could not set state for device {self.name} due to reason {failure}" ) - async def async_assume_state(self, state) -> None: + async def async_assume_state(self, state: str) -> None: """Sync state with api.""" await self._async_set_ac_state_property("on", state != HVACMode.OFF, True) await self.coordinator.async_refresh() + + async def async_enable_timer(self, minutes: int) -> None: + """Enable the timer.""" + new_state = bool(self.device_data.ac_states["on"] is False) + params = { + "minutesFromNow": minutes, + "acState": {**self.device_data.ac_states, "on": new_state}, + } + result = await self.async_send_command("set_timer", params) + + if result["status"] == "success": + return await self.coordinator.async_request_refresh() + raise HomeAssistantError(f"Could not enable timer for device {self.name}") + + async def async_enable_pure_boost( + self, + ac_integration: bool | None = None, + geo_integration: bool | None = None, + indoor_integration: bool | None = None, + outdoor_integration: bool | None = None, + sensitivity: str | None = None, + ) -> None: + """Enable Pure Boost Configuration.""" + + params: dict[str, str | bool] = { + "enabled": True, + } + if sensitivity is not None: + params["sensitivity"] = sensitivity[0] + if indoor_integration is not None: + params["measurementsIntegration"] = indoor_integration + if ac_integration is not None: + params["acIntegration"] = ac_integration + if geo_integration is not None: + params["geoIntegration"] = geo_integration + if outdoor_integration is not None: + params["primeIntegration"] = outdoor_integration + + await self.async_send_command("set_pure_boost", params) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index c4b637e4439..c7aaa30b3db 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Sensibo integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pysensibo.exceptions import AuthenticationError @@ -28,9 +29,7 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Sensibo.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) @@ -75,7 +74,9 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} diff --git a/homeassistant/components/sensibo/const.py b/homeassistant/components/sensibo/const.py index 5fce3822bb2..d6dbe957def 100644 --- a/homeassistant/components/sensibo/const.py +++ b/homeassistant/components/sensibo/const.py @@ -14,10 +14,12 @@ DEFAULT_SCAN_INTERVAL = 60 DOMAIN = "sensibo" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.UPDATE, ] DEFAULT_NAME = "Sensibo" diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index ce85ecf2a38..ac2ec24fac1 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -1,7 +1,7 @@ """Base entity for Sensibo integration.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import async_timeout from pysensibo.model import MotionSensor, SensiboDevice @@ -57,7 +57,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): ) async def async_send_command( - self, command: str, params: dict[str, Any] + self, command: str, params: dict[str, Any] | None = None ) -> dict[str, Any]: """Send command to Sensibo api.""" try: @@ -72,16 +72,20 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): return result async def async_send_api_call( - self, command: str, params: dict[str, Any] + self, command: str, params: dict[str, Any] | None = None ) -> dict[str, Any]: """Send api call.""" result: dict[str, Any] = {"status": None} if command == "set_calibration": + if TYPE_CHECKING: + assert params is not None result = await self._client.async_set_calibration( self._device_id, params["data"], ) if command == "set_ac_state": + if TYPE_CHECKING: + assert params is not None result = await self._client.async_set_ac_state_property( self._device_id, params["name"], @@ -89,6 +93,21 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): params["ac_states"], params["assumed_state"], ) + if command == "set_timer": + if TYPE_CHECKING: + assert params is not None + result = await self._client.async_set_timer(self._device_id, params) + if command == "del_timer": + result = await self._client.async_del_timer(self._device_id) + if command == "set_pure_boost": + if TYPE_CHECKING: + assert params is not None + result = await self._client.async_set_pureboost( + self._device_id, + params, + ) + if command == "reset_filter": + result = await self._client.async_reset_filter(self._device_id) return result @@ -119,6 +138,8 @@ class SensiboMotionBaseEntity(SensiboBaseEntity): ) @property - def sensor_data(self) -> MotionSensor: + def sensor_data(self) -> MotionSensor | None: """Return data for device.""" + if TYPE_CHECKING: + assert self.device_data.motion_sensors return self.device_data.motion_sensors[self._sensor_id] diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 308d991e675..18e93d5efa6 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -2,10 +2,11 @@ "domain": "sensibo", "name": "Sensibo", "documentation": "https://www.home-assistant.io/integrations/sensibo", - "requirements": ["pysensibo==1.0.14"], + "requirements": ["pysensibo==1.0.17"], "config_flow": true, "codeowners": ["@andrey-git", "@gjohansson-ST"], "iot_class": "cloud_polling", + "quality_scale": "platinum", "homekit": { "models": ["Sensibo"] }, diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index fc18e28f1a3..183c4db4b87 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -14,6 +14,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class SensiboEntityDescriptionMixin: @@ -37,9 +39,9 @@ DEVICE_NUMBER_TYPES = ( icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - min_value=-10, - max_value=10, - step=0.1, + native_min_value=-10, + native_max_value=10, + native_step=0.1, ), SensiboNumberEntityDescription( key="calibration_hum", @@ -48,9 +50,9 @@ DEVICE_NUMBER_TYPES = ( icon="mdi:water", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - min_value=-10, - max_value=10, - step=0.1, + native_min_value=-10, + native_max_value=10, + native_step=0.1, ), ) @@ -87,11 +89,12 @@ class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): self._attr_name = f"{self.device_data.name} {entity_description.name}" @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the value from coordinator data.""" - return getattr(self.device_data, self.entity_description.key) + value: float | None = getattr(self.device_data, self.entity_description.key) + return value - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value for calibration.""" data = {self.entity_description.remote_key: value} result = await self.async_send_command("set_calibration", {"data": data}) diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 56b8fbac4fd..f64411ff4dc 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -13,6 +13,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class SensiboSelectDescriptionMixin: @@ -82,7 +84,10 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the current selected option.""" - return getattr(self.device_data, self.entity_description.remote_key) + option: str | None = getattr( + self.device_data, self.entity_description.remote_key + ) + return option @property def options(self) -> list[str]: diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 7ac871a61eb..fad22fdd677 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -1,8 +1,10 @@ """Sensor platform for Sensibo integration.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, Any from pysensibo.model import MotionSensor, SensiboDevice @@ -29,6 +31,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class MotionBaseEntityDescriptionMixin: @@ -41,7 +45,8 @@ class MotionBaseEntityDescriptionMixin: class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" - value_fn: Callable[[SensiboDevice], StateType] + value_fn: Callable[[SensiboDevice], StateType | datetime] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None @dataclass @@ -58,6 +63,15 @@ class SensiboDeviceSensorEntityDescription( """Describes Sensibo Motion sensor entity.""" +FILTER_LAST_RESET_DESCRIPTION = SensiboDeviceSensorEntityDescription( + key="filter_last_reset", + device_class=SensorDeviceClass.TIMESTAMP, + name="Filter Last Reset", + icon="mdi:timer", + value_fn=lambda data: data.filter_last_reset, + extra_fn=None, +) + MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( SensiboMotionSensorEntityDescription( key="rssi", @@ -108,13 +122,28 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( name="PM2.5", icon="mdi:air-filter", value_fn=lambda data: data.pm25, + extra_fn=None, ), SensiboDeviceSensorEntityDescription( key="pure_sensitivity", name="Pure Sensitivity", icon="mdi:air-filter", value_fn=lambda data: data.pure_sensitivity, + extra_fn=None, ), + FILTER_LAST_RESET_DESCRIPTION, +) + +DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( + SensiboDeviceSensorEntityDescription( + key="timer_time", + device_class=SensorDeviceClass.TIMESTAMP, + name="Timer End Time", + icon="mdi:timer", + value_fn=lambda data: data.timer_time, + extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, + ), + FILTER_LAST_RESET_DESCRIPTION, ) @@ -127,19 +156,27 @@ async def async_setup_entry( entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] - entities.extend( - SensiboMotionSensor(coordinator, device_id, sensor_id, sensor_data, description) - for device_id, device_data in coordinator.data.parsed.items() - for sensor_id, sensor_data in device_data.motion_sensors.items() - for description in MOTION_SENSOR_TYPES - if device_data.motion_sensors - ) + for device_id, device_data in coordinator.data.parsed.items(): + if device_data.motion_sensors: + entities.extend( + SensiboMotionSensor( + coordinator, device_id, sensor_id, sensor_data, description + ) + for sensor_id, sensor_data in device_data.motion_sensors.items() + for description in MOTION_SENSOR_TYPES + ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) for device_id, device_data in coordinator.data.parsed.items() for description in PURE_SENSOR_TYPES if device_data.model == "pure" ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + for description in DEVICE_SENSOR_TYPES + if device_data.model != "pure" + ) async_add_entities(entities) @@ -173,6 +210,8 @@ class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): @property def native_value(self) -> StateType: """Return value of sensor.""" + if TYPE_CHECKING: + assert self.sensor_data return self.entity_description.value_fn(self.sensor_data) @@ -197,6 +236,13 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, SensorEntity): self._attr_name = f"{self.device_data.name} {entity_description.name}" @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return value of sensor.""" return self.entity_description.value_fn(self.device_data) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self.entity_description.extra_fn is not None: + return self.entity_description.extra_fn(self.device_data) + return None diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index bbbdb8611e8..9ce13b70eaa 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -16,3 +16,67 @@ assume_state: options: - "on" - "off" +enable_timer: + name: Enable Timer + description: Enable the timer with custom time. + target: + entity: + integration: sensibo + domain: climate + fields: + minutes: + name: Minutes + description: Countdown for timer (for timer state on) + required: false + example: 30 + selector: + number: + min: 0 + step: 1 + mode: box +enable_pure_boost: + name: Enable Pure Boost + description: Enable and configure Pure Boost settings. + target: + entity: + integration: sensibo + domain: climate + fields: + ac_integration: + name: AC Integration + description: Integrate with Air Conditioner. + required: true + example: true + selector: + boolean: + geo_integration: + name: Geo Integration + description: Integrate with Presence. + required: true + example: true + selector: + boolean: + indoor_integration: + name: Indoor Air Quality + description: Integrate with checking indoor air quality. + required: true + example: true + selector: + boolean: + outdoor_integration: + name: Outdoor Air Quality + description: Integrate with checking outdoor air quality. + required: true + example: true + selector: + boolean: + sensitivity: + name: Sensitivity + description: Set the sensitivity for Pure Boost. + required: true + example: "Normal" + selector: + select: + options: + - "Normal" + - "Sensitive" diff --git a/homeassistant/components/sensibo/strings.sensor.json b/homeassistant/components/sensibo/strings.sensor.json new file mode 100644 index 00000000000..2e4e05fba5b --- /dev/null +++ b/homeassistant/components/sensibo/strings.sensor.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensitive" + } + } +} diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py new file mode 100644 index 00000000000..d9cf9417504 --- /dev/null +++ b/homeassistant/components/sensibo/switch.py @@ -0,0 +1,180 @@ +"""Switch platform for Sensibo integration.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from pysensibo.model import SensiboDevice + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SensiboDataUpdateCoordinator +from .entity import SensiboDeviceBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Sensibo base description keys.""" + + value_fn: Callable[[SensiboDevice], bool | None] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None]] | None + command_on: str + command_off: str + remote_key: str + + +@dataclass +class SensiboDeviceSwitchEntityDescription( + SwitchEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Sensibo Switch entity.""" + + +DEVICE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( + SensiboDeviceSwitchEntityDescription( + key="timer_on_switch", + device_class=SwitchDeviceClass.SWITCH, + name="Timer", + icon="mdi:timer", + value_fn=lambda data: data.timer_on, + extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, + command_on="set_timer", + command_off="del_timer", + remote_key="timer_on", + ), +) + +PURE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( + SensiboDeviceSwitchEntityDescription( + key="pure_boost_switch", + device_class=SwitchDeviceClass.SWITCH, + name="Pure Boost", + value_fn=lambda data: data.pure_boost_enabled, + extra_fn=None, + command_on="set_pure_boost", + command_off="set_pure_boost", + remote_key="pure_boost_enabled", + ), +) + + +def build_params(command: str, device_data: SensiboDevice) -> dict[str, Any] | None: + """Build params for turning on switch.""" + if command == "set_timer": + new_state = bool(device_data.ac_states["on"] is False) + params = { + "minutesFromNow": 60, + "acState": {**device_data.ac_states, "on": new_state}, + } + return params + if command == "set_pure_boost": + new_state = bool(device_data.pure_boost_enabled is False) + params = {"enabled": new_state} + if device_data.pure_measure_integration is None: + params["sensitivity"] = "N" + params["measurementsIntegration"] = True + params["acIntegration"] = False + params["geoIntegration"] = False + params["primeIntegration"] = False + return params + return None + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sensibo binary sensor platform.""" + + coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[SensiboDeviceSwitch] = [] + + entities.extend( + SensiboDeviceSwitch(coordinator, device_id, description) + for description in DEVICE_SWITCH_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.model != "pure" + ) + entities.extend( + SensiboDeviceSwitch(coordinator, device_id, description) + for description in PURE_SWITCH_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.model == "pure" + ) + + async_add_entities(entities) + + +class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): + """Representation of a Sensibo Device Switch.""" + + entity_description: SensiboDeviceSwitchEntityDescription + + def __init__( + self, + coordinator: SensiboDataUpdateCoordinator, + device_id: str, + entity_description: SensiboDeviceSwitchEntityDescription, + ) -> None: + """Initiate Sensibo Device Switch.""" + super().__init__( + coordinator, + device_id, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}-{entity_description.key}" + self._attr_name = f"{self.device_data.name} {entity_description.name}" + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + return self.entity_description.value_fn(self.device_data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + params = build_params(self.entity_description.command_on, self.device_data) + result = await self.async_send_command( + self.entity_description.command_on, params + ) + + if result["status"] == "success": + setattr(self.device_data, self.entity_description.remote_key, True) + self.async_write_ha_state() + return await self.coordinator.async_request_refresh() + raise HomeAssistantError( + f"Could not execute {self.entity_description.command_on} for device {self.name}" + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + params = build_params(self.entity_description.command_on, self.device_data) + result = await self.async_send_command( + self.entity_description.command_off, params + ) + + if result["status"] == "success": + setattr(self.device_data, self.entity_description.remote_key, False) + self.async_write_ha_state() + return await self.coordinator.async_request_refresh() + raise HomeAssistantError( + f"Could not execute {self.entity_description.command_off} for device {self.name}" + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self.entity_description.extra_fn: + return self.entity_description.extra_fn(self.device_data) + return None diff --git a/homeassistant/components/sensibo/translations/es.json b/homeassistant/components/sensibo/translations/es.json index b91e1b63f9b..43d9acc2645 100644 --- a/homeassistant/components/sensibo/translations/es.json +++ b/homeassistant/components/sensibo/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada" + "already_configured": "La cuenta ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/sensibo/translations/sensor.ca.json b/homeassistant/components/sensibo/translations/sensor.ca.json new file mode 100644 index 00000000000..1b251c70f7d --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.ca.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensible" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.de.json b/homeassistant/components/sensibo/translations/sensor.de.json new file mode 100644 index 00000000000..ab456f555af --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.de.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Empfindlich" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.el.json b/homeassistant/components/sensibo/translations/sensor.el.json new file mode 100644 index 00000000000..b4e595db882 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.el.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u039a\u03b1\u03bd\u03bf\u03bd\u03b9\u03ba\u03cc", + "s": "\u0395\u03c5\u03b1\u03af\u03c3\u03b8\u03b7\u03c4\u03bf" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.en.json b/homeassistant/components/sensibo/translations/sensor.en.json new file mode 100644 index 00000000000..9ea1818b37c --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.en.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensitive" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.es.json b/homeassistant/components/sensibo/translations/sensor.es.json new file mode 100644 index 00000000000..1b251c70f7d --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.es.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensible" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.et.json b/homeassistant/components/sensibo/translations/sensor.et.json new file mode 100644 index 00000000000..44bdfe9183a --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.et.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Tavaline", + "s": "Tundlik" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.fr.json b/homeassistant/components/sensibo/translations/sensor.fr.json new file mode 100644 index 00000000000..1b251c70f7d --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.fr.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensible" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.hu.json b/homeassistant/components/sensibo/translations/sensor.hu.json new file mode 100644 index 00000000000..38a372f0f78 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.hu.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Norm\u00e1l", + "s": "\u00c9rz\u00e9keny" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.id.json b/homeassistant/components/sensibo/translations/sensor.id.json new file mode 100644 index 00000000000..54a0554ce41 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.id.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensitif" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.it.json b/homeassistant/components/sensibo/translations/sensor.it.json new file mode 100644 index 00000000000..85550808ee9 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.it.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normale", + "s": "Sensibile" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.ja.json b/homeassistant/components/sensibo/translations/sensor.ja.json new file mode 100644 index 00000000000..2921289358f --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u30ce\u30fc\u30de\u30eb", + "s": "\u30bb\u30f3\u30b7\u30c6\u30a3\u30d6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.nl.json b/homeassistant/components/sensibo/translations/sensor.nl.json new file mode 100644 index 00000000000..bd7b06dc940 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.nl.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normaal", + "s": "Gevoelig" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.no.json b/homeassistant/components/sensibo/translations/sensor.no.json new file mode 100644 index 00000000000..e3de20b4636 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.no.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Vanlig", + "s": "F\u00f8lsom" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.pl.json b/homeassistant/components/sensibo/translations/sensor.pl.json new file mode 100644 index 00000000000..1b8ed8a8ed7 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.pl.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "normalna", + "s": "wysoka" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.pt-BR.json b/homeassistant/components/sensibo/translations/sensor.pt-BR.json new file mode 100644 index 00000000000..91d092a1760 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.pt-BR.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sens\u00edvel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.sv.json b/homeassistant/components/sensibo/translations/sensor.sv.json new file mode 100644 index 00000000000..ead64d63cd6 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.sv.json @@ -0,0 +1,7 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.tr.json b/homeassistant/components/sensibo/translations/sensor.tr.json new file mode 100644 index 00000000000..3364a75abe2 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.tr.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Duyarl\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.uk.json b/homeassistant/components/sensibo/translations/sensor.uk.json new file mode 100644 index 00000000000..d93a147307e --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.uk.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u041d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u0438\u0439", + "s": "\u0427\u0443\u0442\u043b\u0438\u0432\u0438\u0439" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.zh-Hant.json b/homeassistant/components/sensibo/translations/sensor.zh-Hant.json new file mode 100644 index 00000000000..5144fdcc699 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.zh-Hant.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u6b63\u5e38", + "s": "\u654f\u611f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sv.json b/homeassistant/components/sensibo/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 6e227a891b0..48304cbd3c5 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class DeviceBaseEntityDescriptionMixin: diff --git a/homeassistant/components/sensibo/util.py b/homeassistant/components/sensibo/util.py index fda9d4a210e..8a181cbe568 100644 --- a/homeassistant/components/sensibo/util.py +++ b/homeassistant/components/sensibo/util.py @@ -31,7 +31,7 @@ async def async_validate_api(hass: HomeAssistant, api_key: str) -> str: raise ConnectionError from err devices = device_query["result"] - user = user_query["result"].get("username") + user: str = user_query["result"].get("username") if not devices: LOGGER.error("Could not retrieve any devices from Sensibo servers") raise NoDevicesError diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6a69c27f9b6..6d35c2a4635 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -264,7 +264,7 @@ class SensorEntity(Entity): _attr_device_class: SensorDeviceClass | str | None _attr_last_reset: datetime | None _attr_native_unit_of_measurement: str | None - _attr_native_value: StateType | date | datetime = None + _attr_native_value: StateType | date | datetime | Decimal = None _attr_state_class: SensorStateClass | str | None _attr_state: None = None # Subclasses of SensorEntity should not set this _attr_unit_of_measurement: None = ( @@ -349,7 +349,7 @@ class SensorEntity(Entity): return None @property - def native_value(self) -> StateType | date | datetime: + def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" return self._attr_native_value @@ -404,10 +404,10 @@ class SensorEntity(Entity): value = value.astimezone(timezone.utc) return value.isoformat(timespec="seconds") - except (AttributeError, TypeError) as err: + except (AttributeError, OverflowError, TypeError) as err: raise ValueError( - f"Invalid datetime: {self.entity_id} has a timestamp device class " - f"but does not provide a datetime state but {type(value)}" + f"Invalid datetime: {self.entity_id} has timestamp device class " + f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err # Received a date value @@ -419,14 +419,14 @@ class SensorEntity(Entity): return value.isoformat() except (AttributeError, TypeError) as err: raise ValueError( - f"Invalid date: {self.entity_id} has a date device class " - f"but does not provide a date state but {type(value)}" + f"Invalid date: {self.entity_id} has date device class " + f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err if ( value is not None and native_unit_of_measurement != unit_of_measurement - and self.device_class in UNIT_CONVERSIONS + and device_class in UNIT_CONVERSIONS ): assert unit_of_measurement assert native_unit_of_measurement @@ -439,8 +439,8 @@ class SensorEntity(Entity): ratio_log = max( 0, log10( - UNIT_RATIOS[self.device_class][native_unit_of_measurement] - / UNIT_RATIOS[self.device_class][unit_of_measurement] + UNIT_RATIOS[device_class][native_unit_of_measurement] + / UNIT_RATIOS[device_class][unit_of_measurement] ), ) prec = prec + floor(ratio_log) @@ -448,7 +448,7 @@ class SensorEntity(Entity): # Suppress ValueError (Could not convert sensor_value to float) with suppress(ValueError): value_f = float(value) # type: ignore[arg-type] - value_f_new = UNIT_CONVERSIONS[self.device_class]( + value_f_new = UNIT_CONVERSIONS[device_class]( value_f, native_unit_of_measurement, unit_of_measurement, diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index 31b4f00c37f..6ff23b43508 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -63,13 +63,23 @@ def async_check_significant_change( absolute_change = 1.0 percentage_change = 2.0 + try: + # New state is invalid, don't report it + new_state_f = float(new_state) + except ValueError: + return False + + try: + # Old state was invalid, we should report again + old_state_f = float(old_state) + except ValueError: + return True + if absolute_change is not None and percentage_change is not None: return _absolute_and_relative_change( - float(old_state), float(new_state), absolute_change, percentage_change + old_state_f, new_state_f, absolute_change, percentage_change ) if absolute_change is not None: - return check_absolute_change( - float(old_state), float(new_state), absolute_change - ) + return check_absolute_change(old_state_f, new_state_f, absolute_change) return None diff --git a/homeassistant/components/sensor/translations/sv.json b/homeassistant/components/sensor/translations/sv.json index 720fac84f59..49c49b16c69 100644 --- a/homeassistant/components/sensor/translations/sv.json +++ b/homeassistant/components/sensor/translations/sv.json @@ -1,24 +1,44 @@ { "device_automation": { "condition_type": { - "is_battery_level": "Aktuell {entity_name} batteriniv\u00e5", - "is_humidity": "Aktuell {entity_name} fuktighet", - "is_illuminance": "Aktuell {entity_name} belysning", - "is_power": "Aktuell {entity_name} str\u00f6m", + "is_battery_level": "Nuvarande {entity_name} batteriniv\u00e5", + "is_carbon_dioxide": "Nuvarande {entity_name} koncentration av koldioxid", + "is_carbon_monoxide": "Nuvarande {entity_name} koncentration av kolmonoxid", + "is_current": "Nuvarande", + "is_energy": "Nuvarande {entity_name} energi", + "is_frequency": "Nuvarande frekvens", + "is_humidity": "Nuvarande {entity_name} fuktighet", + "is_illuminance": "Nuvarande {entity_name} belysning", + "is_nitrogen_dioxide": "Nuvarande {entity_name} koncentration av kv\u00e4vedioxid", + "is_nitrogen_monoxide": "Nuvarande {entity_name} koncentration av kv\u00e4veoxid", + "is_ozone": "Nuvarande {entity_name} koncentration av ozon", + "is_pm1": "Nuvarande {entity_name} koncentration av PM1 partiklar", + "is_pm10": "Nuvarande {entity_name} koncentration av PM10 partiklar", + "is_pm25": "Nuvarande {entity_name} koncentration av PM2.5 partiklar", + "is_power": "Nuvarande {entity_name} effekt", + "is_power_factor": "Nuvarande {entity_name} effektfaktor", "is_pressure": "Aktuellt {entity_name} tryck", - "is_signal_strength": "Aktuell {entity_name} signalstyrka", + "is_reactive_power": "Nuvarande {entity_name} reaktiv effekt", + "is_signal_strength": "Nuvarande {entity_name} signalstyrka", "is_temperature": "Aktuell {entity_name} temperatur", - "is_value": "Aktuellt {entity_name} v\u00e4rde" + "is_value": "Nuvarande {entity_name} v\u00e4rde", + "is_volatile_organic_compounds": "Nuvarande {entity_name} koncentration av flyktiga organiska \u00e4mnen", + "is_voltage": "Nuvarande {entity_name} sp\u00e4nning" }, "trigger_type": { "battery_level": "{entity_name} batteriniv\u00e5 \u00e4ndras", + "energy": "Energif\u00f6r\u00e4ndringar", "humidity": "{entity_name} fuktighet \u00e4ndras", "illuminance": "{entity_name} belysning \u00e4ndras", - "power": "{entity_name} str\u00f6mf\u00f6r\u00e4ndringar", + "power": "{entity_name} effektf\u00f6r\u00e4ndringar", + "power_factor": "effektfaktorf\u00f6r\u00e4ndringar", "pressure": "{entity_name} tryckf\u00f6r\u00e4ndringar", + "reactive_power": "{entity_name} reaktiv effekt\u00e4ndring", "signal_strength": "{entity_name} signalstyrka \u00e4ndras", "temperature": "{entity_name} temperaturf\u00f6r\u00e4ndringar", - "value": "{entity_name} v\u00e4rde \u00e4ndras" + "value": "{entity_name} v\u00e4rde \u00e4ndras", + "volatile_organic_compounds": "{entity_name} koncentrations\u00e4ndringar av flyktiga organiska \u00e4mnen", + "voltage": "{entity_name} sp\u00e4nningsf\u00f6r\u00e4ndringar" } }, "state": { diff --git a/homeassistant/components/sensor/translations/zh-Hans.json b/homeassistant/components/sensor/translations/zh-Hans.json index 4fd2ec4db9d..910aa129864 100644 --- a/homeassistant/components/sensor/translations/zh-Hans.json +++ b/homeassistant/components/sensor/translations/zh-Hans.json @@ -3,17 +3,30 @@ "condition_type": { "is_apparent_power": "{entity_name} \u5f53\u524d\u7684\u89c6\u5728\u529f\u7387", "is_battery_level": "{entity_name} \u5f53\u524d\u7684\u7535\u6c60\u7535\u91cf", + "is_carbon_dioxide": "{entity_name} \u5f53\u524d\u7684\u4e8c\u6c27\u5316\u78b3\u6d53\u5ea6\u6c34\u5e73", + "is_carbon_monoxide": "{entity_name} \u5f53\u524d\u7684\u4e00\u6c27\u5316\u78b3\u6d53\u5ea6\u6c34\u5e73", "is_current": "{entity_name} \u5f53\u524d\u7684\u7535\u6d41", "is_energy": "{entity_name} \u5f53\u524d\u7528\u7535\u91cf", + "is_frequency": "{entity_name} \u5f53\u524d\u7684\u9891\u7387", + "is_gas": "{entity_name} \u5f53\u524d\u7684\u71c3\u6c14", "is_humidity": "{entity_name} \u5f53\u524d\u7684\u6e7f\u5ea6", "is_illuminance": "{entity_name} \u5f53\u524d\u7684\u5149\u7167\u5f3a\u5ea6", + "is_nitrogen_dioxide": "{entity_name} \u5f53\u524d\u7684\u4e8c\u6c27\u5316\u6c2e\u6d53\u5ea6\u6c34\u5e73", + "is_nitrogen_monoxide": "{entity_name} \u5f53\u524d\u7684\u4e00\u6c27\u5316\u6c2e\u6d53\u5ea6\u6c34\u5e73", + "is_nitrous_oxide": "{entity_name} \u5f53\u524d\u7684\u4e00\u6c27\u5316\u4e8c\u6c2e\u6d53\u5ea6\u6c34\u5e73", + "is_ozone": "{entity_name} \u5f53\u524d\u7684\u81ed\u6c27\u6d53\u5ea6\u6c34\u5e73", + "is_pm1": "{entity_name} \u5f53\u524d\u7684 PM1 \u6d53\u5ea6\u6c34\u5e73", + "is_pm10": "{entity_name} \u5f53\u524d\u7684 PM10 \u6d53\u5ea6\u6c34\u5e73", + "is_pm25": "{entity_name} \u5f53\u524d\u7684 PM2.5 \u6d53\u5ea6\u6c34\u5e73", "is_power": "{entity_name} \u5f53\u524d\u7684\u529f\u7387", "is_power_factor": "{entity_name} \u5f53\u524d\u7684\u529f\u7387\u56e0\u6570", "is_pressure": "{entity_name} \u5f53\u524d\u7684\u538b\u529b", "is_reactive_power": "{entity_name} \u5f53\u524d\u7684\u65e0\u529f\u529f\u7387", "is_signal_strength": "{entity_name} \u5f53\u524d\u7684\u4fe1\u53f7\u5f3a\u5ea6", + "is_sulphur_dioxide": "{entity_name} \u5f53\u524d\u7684\u4e8c\u6c27\u5316\u786b\u6d53\u5ea6\u6c34\u5e73", "is_temperature": "{entity_name} \u5f53\u524d\u7684\u6e29\u5ea6", "is_value": "{entity_name} \u5f53\u524d\u7684\u503c", + "is_volatile_organic_compounds": "{entity_name} \u5f53\u524d\u7684\u6325\u53d1\u6027\u6709\u673a\u7269\u6d53\u5ea6\u6c34\u5e73", "is_voltage": "{entity_name} \u5f53\u524d\u7684\u7535\u538b" }, "trigger_type": { diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 3037f1dc374..092358e82f6 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -80,7 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), } - # pylint: disable-next=abstract-class-instantiated sentry_sdk.init( dsn=entry.data[CONF_DSN], environment=entry.options.get(CONF_ENVIRONMENT), diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index b76318f046d..3f01b7bc5f0 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,7 +3,7 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==1.5.12"], + "requirements": ["sentry-sdk==1.6.0"], "codeowners": ["@dcramer", "@frenck"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index 99bb2d8a865..c539e7507eb 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -1,6 +1,8 @@ """Support for Sesame, by CANDY HOUSE.""" from __future__ import annotations +from typing import Any + import pysesame2 import voluptuous as vol @@ -61,11 +63,11 @@ class SesameDevice(LockEntity): """Return True if the device is currently locked, else False.""" return self._is_locked - def lock(self, **kwargs) -> None: + def lock(self, **kwargs: Any) -> None: """Lock the device.""" self._sesame.lock() - def unlock(self, **kwargs) -> None: + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self._sesame.unlock() @@ -80,7 +82,7 @@ class SesameDevice(LockEntity): self._responsive = status["responsive"] @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_DEVICE_ID: self._device_id, diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 4875e2b25e1..b0aae5259dd 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from typing import Any import aiohttp import async_timeout @@ -10,6 +12,7 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -19,7 +22,9 @@ SHARKIQ_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): +async def _validate_input( + hass: core.HomeAssistant, data: Mapping[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" ayla_api = get_ayla_api( username=data[CONF_USERNAME], @@ -45,14 +50,16 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _async_validate_input(self, user_input): + async def _async_validate_input( + self, user_input: Mapping[str, Any] + ) -> tuple[dict[str, str] | None, dict[str, str]]: """Validate form input.""" errors = {} info = None # noinspection PyBroadException try: - info = await validate_input(self.hass, user_input) + info = await _validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -62,9 +69,11 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" return info, errors - async def async_step_user(self, user_input: dict | None = None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: info, errors = await self._async_validate_input(user_input) if info: @@ -76,9 +85,9 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=SHARKIQ_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input: dict | None = None): + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Handle re-auth if login is invalid.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: _, errors = await self._async_validate_input(user_input) diff --git a/homeassistant/components/sharkiq/translations/sv.json b/homeassistant/components/sharkiq/translations/sv.json index 75f4175c9af..cae80c6c25f 100644 --- a/homeassistant/components/sharkiq/translations/sv.json +++ b/homeassistant/components/sharkiq/translations/sv.json @@ -9,6 +9,11 @@ "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } } } } diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 4551fee5590..012f692c579 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -766,14 +766,13 @@ class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.device.firmware_version, new_version, ) - result = None try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - result = await self.device.trigger_ota_update(beta=beta) + await self.device.trigger_ota_update(beta=beta) except (asyncio.TimeoutError, OSError) as err: LOGGER.exception("Error while perform ota update: %s", err) - LOGGER.debug("Result of OTA update call: %s", result) + LOGGER.debug("OTA update call successful") async def shutdown(self) -> None: """Shutdown the wrapper.""" diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 79db9c509f4..b75e1ad2377 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -215,7 +215,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.mode == "color": - if hasattr(self.block, "white"): + if self.wrapper.model in RGBW_MODELS: return ColorMode.RGBW return ColorMode.RGB diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index dcedc32602d..6658daf674f 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -42,12 +42,12 @@ NUMBERS: Final = { key="device|valvepos", icon="mdi:pipe-valve", name="Valve Position", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, available=lambda block: cast(int, block.valveError) != 1, entity_category=EntityCategory.CONFIG, - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, mode=NumberMode("slider"), rest_path="thermostat/0", rest_arg="pos", @@ -62,11 +62,11 @@ def _build_block_description(entry: RegistryEntry) -> BlockNumberDescription: key="", name="", icon=entry.original_icon, - unit_of_measurement=entry.unit_of_measurement, + native_unit_of_measurement=entry.unit_of_measurement, device_class=entry.original_device_class, - min_value=cast(float, entry.capabilities.get("min")), - max_value=cast(float, entry.capabilities.get("max")), - step=cast(float, entry.capabilities.get("step")), + native_min_value=cast(float, entry.capabilities.get("min")), + native_max_value=cast(float, entry.capabilities.get("max")), + native_step=cast(float, entry.capabilities.get("step")), mode=cast(NumberMode, entry.capabilities.get("mode")), ) @@ -97,14 +97,14 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, NumberEntity): entity_description: BlockNumberDescription @property - def value(self) -> float: + def native_value(self) -> float: """Return value of number.""" if self.block is not None: return cast(float, self.attribute_value) return cast(float, self.last_state) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value.""" # Example for Shelly Valve: http://192.168.188.187/thermostat/0?pos=13.0 await self._set_state_full_path( diff --git a/homeassistant/components/shelly/translations/es.json b/homeassistant/components/shelly/translations/es.json index 25cbb8eb30b..0c0011f7297 100644 --- a/homeassistant/components/shelly/translations/es.json +++ b/homeassistant/components/shelly/translations/es.json @@ -6,6 +6,7 @@ }, "error": { "cannot_connect": "No se pudo conectar", + "firmware_not_fully_provisioned": "El dispositivo no est\u00e1 completamente aprovisionado. P\u00f3ngase en contacto con el servicio de asistencia de Shelly", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, diff --git a/homeassistant/components/shelly/translations/sv.json b/homeassistant/components/shelly/translations/sv.json new file mode 100644 index 00000000000..36b53053594 --- /dev/null +++ b/homeassistant/components/shelly/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "credentials": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index a2f7bc744e3..df03882e995 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -94,7 +94,9 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" return SIAOptionsFlowHandler(config_entry) @@ -170,7 +172,7 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class SIAOptionsFlowHandler(config_entries.OptionsFlow): """Handle SIA options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize SIA options flow.""" self.config_entry = config_entry self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/sia/translations/sv.json b/homeassistant/components/sia/translations/sv.json new file mode 100644 index 00000000000..67fd98a8827 --- /dev/null +++ b/homeassistant/components/sia/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/__init__.py b/homeassistant/components/simplepush/__init__.py index 8253cfad8b4..c5782258cb7 100644 --- a/homeassistant/components/simplepush/__init__.py +++ b/homeassistant/components/simplepush/__init__.py @@ -1 +1,39 @@ """The simplepush component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_HASS_CONFIG, DOMAIN + +PLATFORMS = [Platform.NOTIFY] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the simplepush component.""" + + hass.data[DATA_HASS_CONFIG] = config + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up simplepush from a config entry.""" + + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + dict(entry.data), + hass.data[DATA_HASS_CONFIG], + ) + ) + + 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/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py new file mode 100644 index 00000000000..cf08a341114 --- /dev/null +++ b/homeassistant/components/simplepush/config_flow.py @@ -0,0 +1,88 @@ +"""Config flow for simplepush integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from simplepush import UnknownError, send, send_encrypted +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult + +from .const import ATTR_ENCRYPTED, CONF_DEVICE_KEY, CONF_SALT, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def validate_input(entry: dict[str, str]) -> dict[str, str] | None: + """Validate user input.""" + try: + if CONF_PASSWORD in entry: + send_encrypted( + entry[CONF_DEVICE_KEY], + entry[CONF_PASSWORD], + entry[CONF_PASSWORD], + "HA test", + "Message delivered successfully", + ) + else: + send(entry[CONF_DEVICE_KEY], "HA test", "Message delivered successfully") + except UnknownError: + return {"base": "cannot_connect"} + + return None + + +class SimplePushFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for simplepush.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] | None = None + if user_input is not None: + + await self.async_set_unique_id(user_input[CONF_DEVICE_KEY]) + self._abort_if_unique_id_configured() + + self._async_abort_entries_match( + { + CONF_NAME: user_input[CONF_NAME], + } + ) + + if not ( + errors := await self.hass.async_add_executor_job( + validate_input, user_input + ) + ): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_KEY): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Inclusive(CONF_PASSWORD, ATTR_ENCRYPTED): str, + vol.Inclusive(CONF_SALT, ATTR_ENCRYPTED): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + _LOGGER.warning( + "Configuration of the simplepush integration in YAML is deprecated and " + "will be removed in a future release; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + return await self.async_step_user(import_config) diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py new file mode 100644 index 00000000000..6195a5fd1d9 --- /dev/null +++ b/homeassistant/components/simplepush/const.py @@ -0,0 +1,13 @@ +"""Constants for the simplepush integration.""" + +from typing import Final + +DOMAIN: Final = "simplepush" +DEFAULT_NAME: Final = "simplepush" +DATA_HASS_CONFIG: Final = "simplepush_hass_config" + +ATTR_ENCRYPTED: Final = "encrypted" +ATTR_EVENT: Final = "event" + +CONF_DEVICE_KEY: Final = "device_key" +CONF_SALT: Final = "salt" diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 26321d17aef..7c37546485a 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -3,7 +3,8 @@ "name": "Simplepush", "documentation": "https://www.home-assistant.io/integrations/simplepush", "requirements": ["simplepush==1.1.4"], - "codeowners": [], + "codeowners": ["@engrbm87"], + "config_flow": true, "iot_class": "cloud_polling", "loggers": ["simplepush"] } diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 5a83dec69f0..e9cd9813175 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -1,5 +1,10 @@ """Simplepush notification service.""" -from simplepush import send, send_encrypted +from __future__ import annotations + +import logging +from typing import Any + +from simplepush import BadRequest, UnknownError, send, send_encrypted import voluptuous as vol from homeassistant.components.notify import ( @@ -8,14 +13,16 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.components.notify.const import ATTR_DATA +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_EVENT, CONF_PASSWORD +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -ATTR_ENCRYPTED = "encrypted" - -CONF_DEVICE_KEY = "device_key" -CONF_SALT = "salt" +from .const import ATTR_ENCRYPTED, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +# Configuring simplepush under the notify platform will be removed in 2022.9.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DEVICE_KEY): cv.string, @@ -25,34 +32,62 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +_LOGGER = logging.getLogger(__name__) -def get_service(hass, config, discovery_info=None): + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> SimplePushNotificationService | None: """Get the Simplepush notification service.""" - return SimplePushNotificationService(config) + if discovery_info is None: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + return None + + return SimplePushNotificationService(discovery_info) class SimplePushNotificationService(BaseNotificationService): """Implementation of the notification service for Simplepush.""" - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Initialize the Simplepush notification service.""" - self._device_key = config.get(CONF_DEVICE_KEY) - self._event = config.get(CONF_EVENT) - self._password = config.get(CONF_PASSWORD) - self._salt = config.get(CONF_SALT) + self._device_key: str = config[CONF_DEVICE_KEY] + self._event: str | None = config.get(CONF_EVENT) + self._password: str | None = config.get(CONF_PASSWORD) + self._salt: str | None = config.get(CONF_SALT) - def send_message(self, message="", **kwargs): + def send_message(self, message: str, **kwargs: Any) -> None: """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - if self._password: - send_encrypted( - self._device_key, - self._password, - self._salt, - title, - message, - event=self._event, - ) - else: - send(self._device_key, title, message, event=self._event) + # event can now be passed in the service data + event = None + if data := kwargs.get(ATTR_DATA): + event = data.get(ATTR_EVENT) + + # use event from config until YAML config is removed + event = event or self._event + + try: + if self._password: + send_encrypted( + self._device_key, + self._password, + self._salt, + title, + message, + event=event, + ) + else: + send(self._device_key, title, message, event=event) + + except BadRequest: + _LOGGER.error("Bad request. Title or message are too long") + except UnknownError: + _LOGGER.error("Failed to send the notification") diff --git a/homeassistant/components/simplepush/strings.json b/homeassistant/components/simplepush/strings.json new file mode 100644 index 00000000000..0031dc32340 --- /dev/null +++ b/homeassistant/components/simplepush/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "device_key": "The device key of your device", + "event": "The event for the events.", + "password": "The password of the encryption used by your device", + "salt": "The salt used by your device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/simplepush/translations/ca.json b/homeassistant/components/simplepush/translations/ca.json new file mode 100644 index 00000000000..161e1a3c36c --- /dev/null +++ b/homeassistant/components/simplepush/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "user": { + "data": { + "device_key": "Clau del teu dispositiu", + "event": "Esdeveniment per als esdeveniments.", + "name": "Nom", + "password": "Contrasenya del xifrat que utilitza el teu dispositiu", + "salt": "La sal ('salt') que utilitza el teu dispositiu." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/de.json b/homeassistant/components/simplepush/translations/de.json new file mode 100644 index 00000000000..c7f633d312d --- /dev/null +++ b/homeassistant/components/simplepush/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "user": { + "data": { + "device_key": "Der Ger\u00e4teschl\u00fcssel deines Ger\u00e4ts", + "event": "Das Ereignis f\u00fcr die Ereignisse.", + "name": "Name", + "password": "Das Passwort der von deinem Ger\u00e4t verwendeten Verschl\u00fcsselung", + "salt": "Das von deinem Ger\u00e4t verwendete Salt." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/el.json b/homeassistant/components/simplepush/translations/el.json new file mode 100644 index 00000000000..bdcc7239acc --- /dev/null +++ b/homeassistant/components/simplepush/translations/el.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" + }, + "step": { + "user": { + "data": { + "device_key": "\u03a4\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 \u03c3\u03b1\u03c2", + "event": "\u03a4\u03bf \u03b3\u03b5\u03b3\u03bf\u03bd\u03cc\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03b1 \u03b3\u03b5\u03b3\u03bf\u03bd\u03cc\u03c4\u03b1.", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1", + "password": "\u039f \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c4\u03b7\u03c2 \u03ba\u03c1\u03c5\u03c0\u03c4\u03bf\u03b3\u03c1\u03ac\u03c6\u03b7\u03c3\u03b7\u03c2 \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c3\u03b1\u03c2", + "salt": "\u03a4\u03bf \u03b1\u03bb\u03ac\u03c4\u03b9 \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c3\u03b1\u03c2." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/en.json b/homeassistant/components/simplepush/translations/en.json new file mode 100644 index 00000000000..a36a3b2b273 --- /dev/null +++ b/homeassistant/components/simplepush/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "device_key": "The device key of your device", + "event": "The event for the events.", + "name": "Name", + "password": "The password of the encryption used by your device", + "salt": "The salt used by your device." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/et.json b/homeassistant/components/simplepush/translations/et.json new file mode 100644 index 00000000000..2501d992c83 --- /dev/null +++ b/homeassistant/components/simplepush/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "user": { + "data": { + "device_key": "Seadme seadmev\u00f5ti", + "event": "S\u00fcndmuste jaoks m\u00f5eldud s\u00fcndmus.", + "name": "Nimi", + "password": "Seadmes kasutatava kr\u00fcptimise parool", + "salt": "Sadmes kasutatav sool." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/fr.json b/homeassistant/components/simplepush/translations/fr.json new file mode 100644 index 00000000000..546d03bb131 --- /dev/null +++ b/homeassistant/components/simplepush/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "device_key": "La cl\u00e9 d'appareil de votre appareil", + "event": "L'\u00e9v\u00e9nement pour les \u00e9v\u00e9nements.", + "name": "Nom", + "password": "Le mot de passe du chiffrement utilis\u00e9 par votre appareil", + "salt": "Le salage utilis\u00e9 par votre appareil." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/hu.json b/homeassistant/components/simplepush/translations/hu.json new file mode 100644 index 00000000000..e5deb2bf2fc --- /dev/null +++ b/homeassistant/components/simplepush/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "user": { + "data": { + "device_key": "Az eszk\u00f6z kulcsa", + "event": "Az esem\u00e9ny", + "name": "Elnevez\u00e9s", + "password": "A k\u00e9sz\u00fcl\u00e9k \u00e1ltal haszn\u00e1lt titkos\u00edt\u00e1s jelszava", + "salt": "A k\u00e9sz\u00fcl\u00e9k \u00e1ltal haszn\u00e1lt 'salt'." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/id.json b/homeassistant/components/simplepush/translations/id.json new file mode 100644 index 00000000000..35984af5d5f --- /dev/null +++ b/homeassistant/components/simplepush/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "device_key": "Kunci perangkat untuk perangkat Anda", + "event": "Event untuk daftar event", + "name": "Nama", + "password": "Kata sandi enkripsi yang digunakan oleh perangkat Anda", + "salt": "Salt yang digunakan oleh perangkat Anda." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/it.json b/homeassistant/components/simplepush/translations/it.json new file mode 100644 index 00000000000..2ba1bea5d96 --- /dev/null +++ b/homeassistant/components/simplepush/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "user": { + "data": { + "device_key": "La chiave del dispositivo", + "event": "L'evento per gli eventi.", + "name": "Nome", + "password": "La password della crittografia utilizzata dal tuo dispositivo", + "salt": "Il salt utilizzato dal tuo dispositivo." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/ja.json b/homeassistant/components/simplepush/translations/ja.json new file mode 100644 index 00000000000..8e3023e602e --- /dev/null +++ b/homeassistant/components/simplepush/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "device_key": "\u30c7\u30d0\u30a4\u30b9\u306e\u30c7\u30d0\u30a4\u30b9\u30ad\u30fc", + "event": "\u30a4\u30d9\u30f3\u30c8\u306e\u305f\u3081\u306e\u30a4\u30d9\u30f3\u30c8\u3067\u3059\u3002", + "name": "\u540d\u524d", + "password": "\u30c7\u30d0\u30a4\u30b9\u3067\u4f7f\u7528\u3055\u308c\u3066\u3044\u308b\u6697\u53f7\u5316\u306e\u30d1\u30b9\u30ef\u30fc\u30c9", + "salt": "\u30c7\u30d0\u30a4\u30b9\u3067\u4f7f\u7528\u3055\u308c\u3066\u3044\u308bsalt\u3067\u3059\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/nl.json b/homeassistant/components/simplepush/translations/nl.json new file mode 100644 index 00000000000..900bac61bc5 --- /dev/null +++ b/homeassistant/components/simplepush/translations/nl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Naam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/no.json b/homeassistant/components/simplepush/translations/no.json new file mode 100644 index 00000000000..78cf864a33d --- /dev/null +++ b/homeassistant/components/simplepush/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "user": { + "data": { + "device_key": "Enhetsn\u00f8kkelen til enheten din", + "event": "Arrangementet for arrangementene.", + "name": "Navn", + "password": "Passordet til krypteringen som brukes av enheten din", + "salt": "Saltet som brukes av enheten." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/pt-BR.json b/homeassistant/components/simplepush/translations/pt-BR.json new file mode 100644 index 00000000000..bf933fe94da --- /dev/null +++ b/homeassistant/components/simplepush/translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falha ao conectar" + }, + "step": { + "user": { + "data": { + "device_key": "A chave do dispositivo do seu dispositivo", + "event": "O evento para os eventos.", + "name": "Nome", + "password": "A senha da criptografia usada pelo seu dispositivo", + "salt": "O salto utilizado pelo seu dispositivo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/zh-Hant.json b/homeassistant/components/simplepush/translations/zh-Hant.json new file mode 100644 index 00000000000..891f2242467 --- /dev/null +++ b/homeassistant/components/simplepush/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "user": { + "data": { + "device_key": "\u88dd\u7f6e\u4e4b\u88dd\u7f6e\u5bc6\u9470", + "event": "\u4e8b\u4ef6\u7684\u4e8b\u4ef6\u3002", + "name": "\u540d\u7a31", + "password": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b\u52a0\u5bc6\u5bc6\u78bc", + "salt": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b Salt" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 14afc743b23..0b95de2c186 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from typing import Any import async_timeout @@ -97,16 +98,16 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler(config_entry) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth = True - if CONF_USERNAME not in config: + if CONF_USERNAME not in entry_data: # Old versions of the config flow may not have the username by this point; # in that case, we reauth them by making them go through the user flow: return await self.async_step_user() - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def _async_get_email_2fa(self) -> None: diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 4da8f09eff8..a09c273076c 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==2022.06.0"], + "requirements": ["simplisafe-python==2022.06.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/homeassistant/components/simplisafe/translations/es.json b/homeassistant/components/simplisafe/translations/es.json index 44716b87cec..25d8de8bbb3 100644 --- a/homeassistant/components/simplisafe/translations/es.json +++ b/homeassistant/components/simplisafe/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso.", + "email_2fa_timed_out": "Se ha agotado el tiempo de espera de la autenticaci\u00f3n de dos factores basada en el correo electr\u00f3nico.", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { diff --git a/homeassistant/components/simplisafe/translations/he.json b/homeassistant/components/simplisafe/translations/he.json index dda9553f48d..85a9e002b77 100644 --- a/homeassistant/components/simplisafe/translations/he.json +++ b/homeassistant/components/simplisafe/translations/he.json @@ -15,6 +15,11 @@ "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d9\u05e9 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da.", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" }, + "sms_2fa": { + "data": { + "code": "\u05e7\u05d5\u05d3" + } + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/siren/translations/es.json b/homeassistant/components/siren/translations/es.json new file mode 100644 index 00000000000..f3c1468be31 --- /dev/null +++ b/homeassistant/components/siren/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Sirena" +} \ No newline at end of file diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 47e22f5b619..00c7a533590 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -1,101 +1,117 @@ """Support for the Skybell HD Doorbell.""" -import logging +from __future__ import annotations -from requests.exceptions import ConnectTimeout, HTTPError -from skybellpy import Skybell +import asyncio +import os + +from aioskybell import Skybell +from aioskybell.exceptions import SkybellAuthenticationException, SkybellException import voluptuous as vol -from homeassistant.components import persistent_notification -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_PASSWORD, - CONF_USERNAME, - __version__, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by Skybell.com" - -NOTIFICATION_ID = "skybell_notification" -NOTIFICATION_TITLE = "Skybell Sensor Setup" - -DOMAIN = "skybell" -DEFAULT_CACHEDB = "./skybell_cache.pickle" -DEFAULT_ENTITY_NAMESPACE = "skybell" - -AGENT_IDENTIFIER = f"HomeAssistant/{__version__}" +from .const import DEFAULT_CACHEDB, DOMAIN +from .coordinator import SkybellDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, + vol.All( + # Deprecated in Home Assistant 2022.6 + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Skybell component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - try: - cache = hass.config.path(DEFAULT_CACHEDB) - skybell = Skybell( - username=username, - password=password, - get_devices=True, - cache_path=cache, - agent_identifier=AGENT_IDENTIFIER, +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the SkyBell component.""" + hass.data.setdefault(DOMAIN, {}) + + entry_config = {} + if DOMAIN not in config: + return True + for parameter, value in config[DOMAIN].items(): + if parameter == CONF_USERNAME: + entry_config[CONF_EMAIL] = value + else: + entry_config[parameter] = value + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=entry_config, + ) ) - hass.data[DOMAIN] = skybell - except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Skybell service: %s", str(ex)) - persistent_notification.create( - hass, - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - return False + # Clean up unused cache file since we are using an account specific name + # Remove with import + def clean_cache(): + """Clean old cache filename.""" + if os.path.exists(hass.config.path(DEFAULT_CACHEDB)): + os.remove(hass.config.path(DEFAULT_CACHEDB)) + + await hass.async_add_executor_job(clean_cache) + return True -class SkybellDevice(Entity): - """A HA implementation for Skybell devices.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Skybell from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] - def __init__(self, device): - """Initialize a sensor for Skybell device.""" - self._device = device + api = Skybell( + username=email, + password=password, + get_devices=True, + cache_path=hass.config.path(f"./skybell_{entry.unique_id}.pickle"), + session=async_get_clientsession(hass), + ) + try: + devices = await api.async_initialize() + except SkybellAuthenticationException: + return False + except SkybellException as ex: + raise ConfigEntryNotReady(f"Unable to connect to Skybell service: {ex}") from ex - def update(self): - """Update automation state.""" - self._device.refresh() + device_coordinators: list[SkybellDataUpdateCoordinator] = [ + SkybellDataUpdateCoordinator(hass, device) for device in devices + ] + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in device_coordinators + ] + ) + hass.data[DOMAIN][entry.entry_id] = device_coordinators + hass.config_entries.async_setup_platforms(entry, PLATFORMS) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - "device_id": self._device.device_id, - "status": self._device.status, - "location": self._device.location, - "wifi_ssid": self._device.wifi_ssid, - "wifi_status": self._device.wifi_status, - "last_check_in": self._device.last_check_in, - "motion_threshold": self._device.motion_threshold, - "video_profile": self._device.video_profile, - } + 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/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index bf8ffcfce9d..05f007e9455 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,9 +1,7 @@ """Binary sensor support for the Skybell HD Doorbell.""" from __future__ import annotations -from datetime import timedelta -from typing import Any - +from aioskybell.helpers import const as CONST import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -12,36 +10,33 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from . import DOMAIN +from .coordinator import SkybellDataUpdateCoordinator +from .entity import SkybellEntity -SCAN_INTERVAL = timedelta(seconds=10) - - -BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { - "button": BinarySensorEntityDescription( - key="device:sensor:button", +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="button", name="Button", device_class=BinarySensorDeviceClass.OCCUPANCY, ), - "motion": BinarySensorEntityDescription( - key="device:sensor:motion", + BinarySensorEntityDescription( + key="motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION, ), -} - +) +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( cv.ensure_list, [vol.In(BINARY_SENSOR_TYPES)] ), @@ -49,53 +44,33 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - binary_sensors = [ - SkybellBinarySensor(device, BINARY_SENSOR_TYPES[sensor_type]) - for device in skybell.get_devices() - for sensor_type in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(binary_sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellBinarySensor(coordinator, sensor) + for sensor in BINARY_SENSOR_TYPES + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -class SkybellBinarySensor(SkybellDevice, BinarySensorEntity): +class SkybellBinarySensor(SkybellEntity, BinarySensorEntity): """A binary sensor implementation for Skybell devices.""" def __init__( self, - device, + coordinator: SkybellDataUpdateCoordinator, description: BinarySensorEntityDescription, - ): + ) -> None: """Initialize a binary sensor for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - self._event: dict[Any, Any] = {} - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attrs = super().extra_state_attributes - - attrs["event_date"] = self._event.get("createdAt") - - return attrs - - def update(self): - """Get the latest data and updates the state.""" - super().update() + super().__init__(coordinator, description) + self._event: dict[str, str] = {} + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" event = self._device.latest(self.entity_description.key) - - self._attr_is_on = bool(event and event.get("id") != self._event.get("id")) - - self._event = event or {} + self._attr_is_on = bool(event.get(CONST.ID) != self._event.get(CONST.ID)) + self._event = event + super()._handle_coordinator_update() diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 96989fad747..499f1f3bfca 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,31 +1,35 @@ """Camera support for the Skybell HD Doorbell.""" from __future__ import annotations -from datetime import timedelta -import logging - -import requests +from aiohttp import web +from haffmpeg.camera import CameraMjpeg import voluptuous as vol -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, + Camera, + CameraEntityDescription, +) +from homeassistant.components.ffmpeg import get_ffmpeg_manager +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as SKYBELL_DOMAIN, SkybellDevice - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=90) - -IMAGE_AVATAR = "avatar" -IMAGE_ACTIVITY = "activity" - -CONF_ACTIVITY_NAME = "activity_name" -CONF_AVATAR_NAME = "avatar_name" +from .const import ( + CONF_ACTIVITY_NAME, + CONF_AVATAR_NAME, + DOMAIN, + IMAGE_ACTIVITY, + IMAGE_AVATAR, +) +from .coordinator import SkybellDataUpdateCoordinator +from .entity import SkybellEntity +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=[IMAGE_AVATAR]): vol.All( @@ -36,71 +40,62 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( + CameraEntityDescription(key="activity", name="Last Activity"), + CameraEntityDescription(key="avatar", name="Camera"), +) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - cond = config[CONF_MONITORED_CONDITIONS] - names = {} - names[IMAGE_ACTIVITY] = config.get(CONF_ACTIVITY_NAME) - names[IMAGE_AVATAR] = config.get(CONF_AVATAR_NAME) - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [] - for device in skybell.get_devices(): - for camera_type in cond: - sensors.append(SkybellCamera(device, camera_type, names.get(camera_type))) - - add_entities(sensors, True) + """Set up Skybell switch.""" + entities = [] + for description in CAMERA_TYPES: + for coordinator in hass.data[DOMAIN][entry.entry_id]: + if description.key == "avatar": + entities.append(SkybellCamera(coordinator, description)) + else: + entities.append(SkybellActivityCamera(coordinator, description)) + async_add_entities(entities) -class SkybellCamera(SkybellDevice, Camera): +class SkybellCamera(SkybellEntity, Camera): """A camera implementation for Skybell devices.""" - def __init__(self, device, camera_type, name=None): + def __init__( + self, + coordinator: SkybellDataUpdateCoordinator, + description: EntityDescription, + ) -> None: """Initialize a camera for a Skybell device.""" - self._type = camera_type - SkybellDevice.__init__(self, device) + super().__init__(coordinator, description) Camera.__init__(self) - if name is not None: - self._name = f"{self._device.name} {name}" - else: - self._name = self._device.name - self._url = None - self._response = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def image_url(self): - """Get the camera image url based on type.""" - if self._type == IMAGE_ACTIVITY: - return self._device.activity_image - return self._device.image - - def camera_image( + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Get the latest camera image.""" - super().update() + return self._device.images[self.entity_description.key] - if self._url != self.image_url: - self._url = self.image_url - try: - self._response = requests.get(self._url, stream=True, timeout=10) - except requests.HTTPError as err: - _LOGGER.warning("Failed to get camera image: %s", err) - self._response = None +class SkybellActivityCamera(SkybellCamera): + """A camera implementation for latest Skybell activity.""" - if not self._response: - return None + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse: + """Generate an HTTP MJPEG stream from the latest recorded activity.""" + stream = CameraMjpeg(get_ffmpeg_manager(self.hass).binary) + url = await self.coordinator.device.async_get_activity_video_url() + await stream.open_camera(url, extra_cmd="-r 210") - return self._response.content + try: + return await async_aiohttp_proxy_stream( + self.hass, + request, + await stream.get_reader(), + get_ffmpeg_manager(self.hass).ffmpeg_stream_content_type, + ) + finally: + await stream.close() diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py new file mode 100644 index 00000000000..7b7b43788b3 --- /dev/null +++ b/homeassistant/components/skybell/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Skybell integration.""" +from __future__ import annotations + +from typing import Any + +from aioskybell import Skybell, exceptions +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.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + + +class SkybellFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Skybell.""" + + async def async_step_import(self, user_input: ConfigType) -> FlowResult: + """Import a config entry from configuration.yaml.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + email = user_input[CONF_EMAIL].lower() + password = user_input[CONF_PASSWORD] + + self._async_abort_entries_match({CONF_EMAIL: email}) + user_id, error = await self._async_validate_input(email, password) + if error is None: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=email, + data={CONF_EMAIL: email, CONF_PASSWORD: password}, + ) + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL, default=user_input.get(CONF_EMAIL)): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def _async_validate_input(self, email: str, password: str) -> tuple: + """Validate login credentials.""" + skybell = Skybell( + username=email, + password=password, + disable_cache=True, + session=async_get_clientsession(self.hass), + ) + try: + await skybell.async_initialize() + except exceptions.SkybellAuthenticationException: + return None, "invalid_auth" + except exceptions.SkybellException: + return None, "cannot_connect" + except Exception: # pylint: disable=broad-except + return None, "unknown" + return skybell.user_id, None diff --git a/homeassistant/components/skybell/const.py b/homeassistant/components/skybell/const.py new file mode 100644 index 00000000000..d8f7e4992d5 --- /dev/null +++ b/homeassistant/components/skybell/const.py @@ -0,0 +1,14 @@ +"""Constants for the Skybell HD Doorbell.""" +import logging +from typing import Final + +CONF_ACTIVITY_NAME = "activity_name" +CONF_AVATAR_NAME = "avatar_name" +DEFAULT_CACHEDB = "./skybell_cache.pickle" +DEFAULT_NAME = "SkyBell" +DOMAIN: Final = "skybell" + +IMAGE_AVATAR = "avatar" +IMAGE_ACTIVITY = "activity" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py new file mode 100644 index 00000000000..26545609bd5 --- /dev/null +++ b/homeassistant/components/skybell/coordinator.py @@ -0,0 +1,34 @@ +"""Data update coordinator for the Skybell integration.""" + +from datetime import timedelta + +from aioskybell import SkybellDevice, SkybellException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +class SkybellDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Skybell integration.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, device: SkybellDevice) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=device.name, + update_interval=timedelta(seconds=30), + ) + self.device = device + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + try: + await self.device.async_update() + except SkybellException as err: + raise UpdateFailed(f"Failed to communicate with device: {err}") from err diff --git a/homeassistant/components/skybell/entity.py b/homeassistant/components/skybell/entity.py new file mode 100644 index 00000000000..0e5c246a8ed --- /dev/null +++ b/homeassistant/components/skybell/entity.py @@ -0,0 +1,49 @@ +"""Entity representing a Skybell HD Doorbell.""" +from __future__ import annotations + +from aioskybell import SkybellDevice + +from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import SkybellDataUpdateCoordinator + + +class SkybellEntity(CoordinatorEntity[SkybellDataUpdateCoordinator]): + """An HA implementation for Skybell entity.""" + + _attr_attribution = "Data provided by Skybell.com" + + def __init__( + self, coordinator: SkybellDataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize a SkyBell entity.""" + super().__init__(coordinator) + self.entity_description = description + if description.name != coordinator.device.name: + self._attr_name = f"{self._device.name} {description.name}" + self._attr_unique_id = f"{self._device.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device.device_id)}, + manufacturer=DEFAULT_NAME, + model=self._device.type, + name=self._device.name, + sw_version=self._device.firmware_ver, + ) + if self._device.mac: + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, self._device.mac) + } + + @property + def _device(self) -> SkybellDevice: + """Return the device.""" + return self.coordinator.device + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index 7fbd1519e26..845be44a34b 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -1,82 +1,63 @@ """Light/LED support for the Skybell HD Doorbell.""" from __future__ import annotations +from typing import Any + from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util -from . import DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from .const import DOMAIN +from .entity import SkybellEntity -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [] - for device in skybell.get_devices(): - sensors.append(SkybellLight(device)) - - add_entities(sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellLight( + coordinator, + LightEntityDescription( + key=coordinator.device.name, + name=coordinator.device.name, + ), + ) + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -def _to_skybell_level(level): - """Convert the given Home Assistant light level (0-255) to Skybell (0-100).""" - return int((level * 100) / 255) +class SkybellLight(SkybellEntity, LightEntity): + """A light implementation for Skybell devices.""" + _attr_supported_color_modes = {ColorMode.BRIGHTNESS, ColorMode.RGB} -def _to_hass_level(level): - """Convert the given Skybell (0-100) light level to Home Assistant (0-255).""" - return int((level * 255) / 100) - - -class SkybellLight(SkybellDevice, LightEntity): - """A binary sensor implementation for Skybell devices.""" - - _attr_color_mode = ColorMode.HS - _attr_supported_color_modes = {ColorMode.HS} - - def __init__(self, device): - """Initialize a light for a Skybell device.""" - super().__init__(device) - self._attr_name = device.name - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - if ATTR_HS_COLOR in kwargs: - rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) - self._device.led_rgb = rgb - elif ATTR_BRIGHTNESS in kwargs: - self._device.led_intensity = _to_skybell_level(kwargs[ATTR_BRIGHTNESS]) - else: - self._device.led_intensity = _to_skybell_level(255) + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs[ATTR_RGB_COLOR] + await self._device.async_set_setting(ATTR_RGB_COLOR, rgb) + if ATTR_BRIGHTNESS in kwargs: + level = int((kwargs.get(ATTR_BRIGHTNESS, 0) * 100) / 255) + await self._device.async_set_setting(ATTR_BRIGHTNESS, level) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - self._device.led_intensity = 0 + await self._device.async_set_setting(ATTR_BRIGHTNESS, 0) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.led_intensity > 0 @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" - return _to_hass_level(self._device.led_intensity) - - @property - def hs_color(self): - """Return the color of the light.""" - return color_util.color_RGB_to_hs(*self._device.led_rgb) + return int((self._device.led_intensity * 255) / 100) diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index ce166179969..c0e66aa5462 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -1,9 +1,11 @@ { "domain": "skybell", "name": "SkyBell", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/skybell", - "requirements": ["skybellpy==0.6.3"], - "codeowners": [], + "requirements": ["aioskybell==22.6.1"], + "dependencies": ["ffmpeg"], + "codeowners": ["@tkdrob"], "iot_class": "cloud_polling", - "loggers": ["skybellpy"] + "loggers": ["aioskybell"] } diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 5922bb05382..eeb81e07aaf 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -1,40 +1,106 @@ """Sensor support for Skybell Doorbells.""" from __future__ import annotations -from datetime import timedelta +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +from aioskybell import SkybellDevice +from aioskybell.helpers import const as CONST import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from .entity import DOMAIN, SkybellEntity -SCAN_INTERVAL = timedelta(seconds=30) -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +@dataclass +class SkybellSensorEntityDescription(SensorEntityDescription): + """Class to describe a Skybell sensor.""" + + value_fn: Callable[[SkybellDevice], Any] = lambda val: val + + +SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( + SkybellSensorEntityDescription( key="chime_level", name="Chime Level", icon="mdi:bell-ring", + value_fn=lambda device: device.outdoor_chime_level, + ), + SkybellSensorEntityDescription( + key="last_button_event", + name="Last Button Event", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: device.latest("button").get(CONST.CREATED_AT), + ), + SkybellSensorEntityDescription( + key="last_motion_event", + name="Last Motion Event", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: device.latest("motion").get(CONST.CREATED_AT), + ), + SkybellSensorEntityDescription( + key=CONST.ATTR_LAST_CHECK_IN, + name="Last Check in", + icon="mdi:clock", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.last_check_in, + ), + SkybellSensorEntityDescription( + key="motion_threshold", + name="Motion Threshold", + icon="mdi:walk", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.motion_threshold, + ), + SkybellSensorEntityDescription( + key="video_profile", + name="Video Profile", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.video_profile, + ), + SkybellSensorEntityDescription( + key=CONST.ATTR_WIFI_SSID, + name="Wifi SSID", + icon="mdi:wifi-settings", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.wifi_ssid, + ), + SkybellSensorEntityDescription( + key=CONST.ATTR_WIFI_STATUS, + name="Wifi Status", + icon="mdi:wifi-strength-3", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.wifi_status, ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] +MONITORED_CONDITIONS = SENSOR_TYPES +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] ), @@ -42,41 +108,24 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [ - SkybellSensor(device, description) - for device in skybell.get_devices() + """Set up Skybell sensor.""" + async_add_entities( + SkybellSensor(coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id] for description in SENSOR_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(sensors, True) + if coordinator.device.owner or description.key not in CONST.ATTR_OWNER_STATS + ) -class SkybellSensor(SkybellDevice, SensorEntity): +class SkybellSensor(SkybellEntity, SensorEntity): """A sensor implementation for Skybell devices.""" - def __init__( - self, - device, - description: SensorEntityDescription, - ): - """Initialize a sensor for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" + entity_description: SkybellSensorEntityDescription - def update(self): - """Get the latest data and updates the state.""" - super().update() - - if self.entity_description.key == "chime_level": - self._attr_native_value = self._device.outdoor_chime_level + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/ialarm_xr/strings.json b/homeassistant/components/skybell/strings.json similarity index 58% rename from homeassistant/components/ialarm_xr/strings.json rename to homeassistant/components/skybell/strings.json index ea4f91fdbb9..e48a75c12bd 100644 --- a/homeassistant/components/ialarm_xr/strings.json +++ b/homeassistant/components/skybell/strings.json @@ -3,20 +3,19 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]", - "username": "[%key:common::config_flow::data::username%]", + "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } } }, "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout": "[%key:common::config_flow::error::timeout_connect%]", "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_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index 2873ad2c081..d4f2817141c 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -1,6 +1,8 @@ """Switch support for the Skybell HD Doorbell.""" from __future__ import annotations +from typing import Any, cast + import voluptuous as vol from homeassistant.components.switch import ( @@ -8,80 +10,64 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from .const import DOMAIN +from .entity import SkybellEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="do_not_disturb", name="Do Not Disturb", ), + SwitchEntityDescription( + key="do_not_ring", + name="Do Not Ring", + ), SwitchEntityDescription( key="motion_sensor", name="Motion Sensor", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SWITCH_TYPES] - +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] + cv.ensure_list, [vol.In(SWITCH_TYPES)] ), } ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - switches = [ - SkybellSwitch(device, description) - for device in skybell.get_devices() + """Set up the SkyBell switch.""" + async_add_entities( + SkybellSwitch(coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id] for description in SWITCH_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(switches, True) + ) -class SkybellSwitch(SkybellDevice, SwitchEntity): +class SkybellSwitch(SkybellEntity, SwitchEntity): """A switch implementation for Skybell devices.""" - def __init__( - self, - device, - description: SwitchEntityDescription, - ): - """Initialize a light for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - setattr(self._device, self.entity_description.key, True) + await self._device.async_set_setting(self.entity_description.key, True) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - setattr(self._device, self.entity_description.key, False) + await self._device.async_set_setting(self.entity_description.key, False) @property - def is_on(self): - """Return true if device is on.""" - return getattr(self._device, self.entity_description.key) + def is_on(self) -> bool: + """Return true if entity is on.""" + return cast(bool, getattr(self._device, self.entity_description.key)) diff --git a/homeassistant/components/skybell/translations/bg.json b/homeassistant/components/skybell/translations/bg.json new file mode 100644 index 00000000000..f3f182bbc3a --- /dev/null +++ b/homeassistant/components/skybell/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/ca.json b/homeassistant/components/skybell/translations/ca.json new file mode 100644 index 00000000000..6aea0bdfc8f --- /dev/null +++ b/homeassistant/components/skybell/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/de.json b/homeassistant/components/skybell/translations/de.json new file mode 100644 index 00000000000..65a21e4b8f5 --- /dev/null +++ b/homeassistant/components/skybell/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/el.json b/homeassistant/components/skybell/translations/el.json new file mode 100644 index 00000000000..870068a34fd --- /dev/null +++ b/homeassistant/components/skybell/translations/el.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "reauth_successful": "\u039f \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "invalid_auth": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", + "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/en.json b/homeassistant/components/skybell/translations/en.json new file mode 100644 index 00000000000..d996004e5c4 --- /dev/null +++ b/homeassistant/components/skybell/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/es.json b/homeassistant/components/skybell/translations/es.json new file mode 100644 index 00000000000..cc93b536c38 --- /dev/null +++ b/homeassistant/components/skybell/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + }, + "error": { + "cannot_connect": "Error al conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "email": "Correo electronico", + "password": "Contrase\u00f1a" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/et.json b/homeassistant/components/skybell/translations/et.json new file mode 100644 index 00000000000..f6f6392d7a0 --- /dev/null +++ b/homeassistant/components/skybell/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/fr.json b/homeassistant/components/skybell/translations/fr.json new file mode 100644 index 00000000000..457e7c48157 --- /dev/null +++ b/homeassistant/components/skybell/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification non valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "email": "Courriel", + "password": "Mot de passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/he.json b/homeassistant/components/skybell/translations/he.json new file mode 100644 index 00000000000..28e8ddd34c9 --- /dev/null +++ b/homeassistant/components/skybell/translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/hu.json b/homeassistant/components/skybell/translations/hu.json new file mode 100644 index 00000000000..98b9ee3f016 --- /dev/null +++ b/homeassistant/components/skybell/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/id.json b/homeassistant/components/skybell/translations/id.json new file mode 100644 index 00000000000..d59082eb80b --- /dev/null +++ b/homeassistant/components/skybell/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/it.json b/homeassistant/components/skybell/translations/it.json new file mode 100644 index 00000000000..39d4856dbe4 --- /dev/null +++ b/homeassistant/components/skybell/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/ja.json b/homeassistant/components/skybell/translations/ja.json new file mode 100644 index 00000000000..9e2d562206f --- /dev/null +++ b/homeassistant/components/skybell/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/nl.json b/homeassistant/components/skybell/translations/nl.json new file mode 100644 index 00000000000..b937f595704 --- /dev/null +++ b/homeassistant/components/skybell/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie geslaagd" + }, + "error": { + "cannot_connect": "Verbindingsfout", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/no.json b/homeassistant/components/skybell/translations/no.json new file mode 100644 index 00000000000..8701b272f12 --- /dev/null +++ b/homeassistant/components/skybell/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/pl.json b/homeassistant/components/skybell/translations/pl.json new file mode 100644 index 00000000000..32e23d406ab --- /dev/null +++ b/homeassistant/components/skybell/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/pt-BR.json b/homeassistant/components/skybell/translations/pt-BR.json new file mode 100644 index 00000000000..9fed8c0da02 --- /dev/null +++ b/homeassistant/components/skybell/translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "A conta j\u00e1 foi configurada", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" + }, + "error": { + "cannot_connect": "Falha ao conectar", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Senha" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/sv.json b/homeassistant/components/skybell/translations/sv.json new file mode 100644 index 00000000000..bb5b9d188a6 --- /dev/null +++ b/homeassistant/components/skybell/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "L\u00f6senord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/tr.json b/homeassistant/components/skybell/translations/tr.json new file mode 100644 index 00000000000..68bd9029559 --- /dev/null +++ b/homeassistant/components/skybell/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/uk.json b/homeassistant/components/skybell/translations/uk.json new file mode 100644 index 00000000000..19744315085 --- /dev/null +++ b/homeassistant/components/skybell/translations/uk.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/zh-Hant.json b/homeassistant/components/skybell/translations/zh-Hant.json new file mode 100644 index 00000000000..1e614212c45 --- /dev/null +++ b/homeassistant/components/skybell/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/slack/translations/pt-BR.json b/homeassistant/components/slack/translations/pt-BR.json index a6299848bfc..834dea1bf0a 100644 --- a/homeassistant/components/slack/translations/pt-BR.json +++ b/homeassistant/components/slack/translations/pt-BR.json @@ -4,17 +4,17 @@ "already_configured": "O servi\u00e7o j\u00e1 est\u00e1 configurado" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "unknown": "Erro inesperado" }, "step": { "user": { "data": { - "api_key": "Chave de API", + "api_key": "Chave da API", "default_channel": "Canal padr\u00e3o", "icon": "\u00cdcone", - "username": "Nome de usu\u00e1rio" + "username": "Usu\u00e1rio" }, "data_description": { "api_key": "O token da API do Slack a ser usado para enviar mensagens do Slack.", diff --git a/homeassistant/components/slack/translations/sv.json b/homeassistant/components/slack/translations/sv.json new file mode 100644 index 00000000000..34e67b311a2 --- /dev/null +++ b/homeassistant/components/slack/translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "api_key": "API-nyckel", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sleepiq/config_flow.py b/homeassistant/components/sleepiq/config_flow.py index 49f14eff0b9..16034b64e8b 100644 --- a/homeassistant/components/sleepiq/config_flow.py +++ b/homeassistant/components/sleepiq/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure SleepIQ component.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -79,12 +80,12 @@ class SleepIQFlowHandler(ConfigFlow, domain=DOMAIN): last_step=True, ) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + 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(user_input) + return await self.async_step_reauth_confirm(dict(entry_data)) async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index b9aca69b3f4..01ba90360ba 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -70,9 +70,9 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, - min_value=5, - max_value=100, - step=5, + native_min_value=5, + native_max_value=100, + native_step=5, name=ENTITY_TYPES[FIRMNESS], icon=ICON_OCCUPIED, value_fn=lambda sleeper: cast(float, sleeper.sleep_number), @@ -82,9 +82,9 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { ), ACTUATOR: SleepIQNumberEntityDescription( key=ACTUATOR, - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, name=ENTITY_TYPES[ACTUATOR], icon=ICON_OCCUPIED, value_fn=lambda actuator: cast(float, actuator.position), @@ -152,10 +152,10 @@ class SleepIQNumberEntity(SleepIQBedEntity, NumberEntity): @callback def _async_update_attrs(self) -> None: """Update number attributes.""" - self._attr_value = float(self.entity_description.value_fn(self.device)) + self._attr_native_value = float(self.entity_description.value_fn(self.device)) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the number value.""" await self.entity_description.set_value_fn(self.device, int(value)) - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/translations/es.json b/homeassistant/components/sleepiq/translations/es.json index 57b8f421844..033941b21d2 100644 --- a/homeassistant/components/sleepiq/translations/es.json +++ b/homeassistant/components/sleepiq/translations/es.json @@ -13,6 +13,7 @@ "data": { "password": "Contrase\u00f1a" }, + "description": "La integraci\u00f3n de SleepIQ necesita volver a autenticar su cuenta {username} .", "title": "Reautenticaci\u00f3n de la integraci\u00f3n" }, "user": { diff --git a/homeassistant/components/sleepiq/translations/sv.json b/homeassistant/components/sleepiq/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/sleepiq/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/slide/cover.py b/homeassistant/components/slide/cover.py index 7841be50977..866d3d40307 100644 --- a/homeassistant/components/slide/cover.py +++ b/homeassistant/components/slide/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.const import ATTR_ID, STATE_CLOSED, STATE_CLOSING, STATE_OPENING @@ -51,29 +52,29 @@ class SlideCover(CoverEntity): self._invert = slide["invert"] @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._slide["state"] == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._slide["state"] == STATE_CLOSING @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return None if status is unknown, True if closed, else False.""" if self._slide["state"] is None: return None return self._slide["state"] == STATE_CLOSED @property - def available(self): + def available(self) -> bool: """Return False if state is not available.""" return self._slide["online"] @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of cover shutter.""" if (pos := self._slide["pos"]) is not None: if (1 - pos) <= DEFAULT_OFFSET or pos <= DEFAULT_OFFSET: @@ -83,21 +84,21 @@ class SlideCover(CoverEntity): pos = int(pos * 100) return pos - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._slide["state"] = STATE_OPENING await self._api.slide_open(self._id) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._slide["state"] = STATE_CLOSING await self._api.slide_close(self._id) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._api.slide_stop(self._id) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] / 100 if not self._invert: diff --git a/homeassistant/components/slimproto/manifest.json b/homeassistant/components/slimproto/manifest.json index 23b3198d7e4..1e076046b44 100644 --- a/homeassistant/components/slimproto/manifest.json +++ b/homeassistant/components/slimproto/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "iot_class": "local_push", "documentation": "https://www.home-assistant.io/integrations/slimproto", - "requirements": ["aioslimproto==2.0.1"], + "requirements": ["aioslimproto==2.1.1"], "codeowners": ["@marcelveldt"], "after_dependencies": ["media_source"] } diff --git a/homeassistant/components/sma/translations/bg.json b/homeassistant/components/sma/translations/bg.json new file mode 100644 index 00000000000..cef3726d759 --- /dev/null +++ b/homeassistant/components/sma/translations/bg.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/translations/bg.json b/homeassistant/components/smappee/translations/bg.json index 9173bdc0bc7..7b8f03499a6 100644 --- a/homeassistant/components/smappee/translations/bg.json +++ b/homeassistant/components/smappee/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430." }, "flow_title": "{name}", diff --git a/homeassistant/components/smart_meter_texas/translations/sv.json b/homeassistant/components/smart_meter_texas/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/smart_meter_texas/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9bc287b054c..87d20b4533f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -106,7 +106,6 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: Capability.air_conditioner_mode, Capability.demand_response_load_control, Capability.air_conditioner_fan_mode, - Capability.power_consumption_report, Capability.relative_humidity_measurement, Capability.switch, Capability.temperature_measurement, @@ -422,10 +421,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): "drlc_status_level", "drlc_status_start", "drlc_status_override", - "power_consumption_start", - "power_consumption_power", - "power_consumption_energy", - "power_consumption_end", ] state_attributes = {} for attribute in attributes: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 578b13879e8..0ff3a82d788 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import Any from pysmartthings import Attribute, Capability @@ -69,6 +70,8 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" + _attr_supported_features: int + def __init__(self, device): """Initialize the cover class.""" super().__init__(device) @@ -81,7 +84,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): if Capability.switch_level in device.capabilities: self._attr_supported_features |= CoverEntityFeature.SET_POSITION - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" # Same command for all 3 supported capabilities await self._device.close(set_status=True) @@ -89,7 +92,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): # the entity state ahead of receiving the confirming push updates self.async_schedule_update_ha_state(True) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" # Same for all capability types await self._device.open(set_status=True) @@ -97,27 +100,24 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): # the entity state ahead of receiving the confirming push updates self.async_schedule_update_ha_state(True) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" if not self._attr_supported_features & CoverEntityFeature.SET_POSITION: return # Do not set_status=True as device will report progress. await self._device.set_level(kwargs[ATTR_POSITION], 0) - async def async_update(self): + async def async_update(self) -> None: """Update the attrs of the cover.""" - value = None if Capability.door_control in self._device.capabilities: self._device_class = CoverDeviceClass.DOOR - value = self._device.status.door + self._state = VALUE_TO_STATE.get(self._device.status.door) elif Capability.window_shade in self._device.capabilities: self._device_class = CoverDeviceClass.SHADE - value = self._device.status.window_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 - value = self._device.status.door - - self._state = VALUE_TO_STATE.get(value) + self._state = VALUE_TO_STATE.get(self._device.status.door) self._state_attrs = {} battery = self._device.status.attributes[Attribute.battery].value @@ -125,35 +125,35 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self._state_attrs[ATTR_BATTERY_LEVEL] = battery @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._state == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._state == STATE_CLOSING @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._state == STATE_CLOSED: return True return None if self._state is None else False @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover.""" if not self._attr_supported_features & CoverEntityFeature.SET_POSITION: return None return self._device.status.level @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Define this cover as a garage door.""" return self._device_class @property - def extra_state_attributes(self): + 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 d3f3affa358..7278f350dc1 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence import math +from typing import Any from pysmartthings import Capability @@ -52,8 +53,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_supported_features = FanEntityFeature.SET_SPEED - async def async_set_percentage(self, percentage: int | None) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" + await self._async_set_percentage(percentage) + + async def _async_set_percentage(self, percentage: int | None) -> None: if percentage is None: await self._device.switch_on(set_status=True) elif percentage == 0: @@ -69,12 +73,12 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn the fan on.""" - await self.async_set_percentage(percentage) + await self._async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self._device.switch_off(set_status=True) # State is set optimistically in the command above, therefore update diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index be3fe949061..c0fbc32fa19 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import Any from pysmartthings import Attribute, Capability @@ -50,23 +51,23 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" await self._device.lock(set_status=True) self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" await self._device.unlock(set_status=True) self.async_write_ha_state() @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._device.status.lock == ST_STATE_LOCKED @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" state_attrs = {} status = self._device.status.attributes[Attribute.lock] diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 872921199f0..64869347228 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -553,7 +553,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add binary sensors for a config entry.""" + """Add sensors for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] entities: list[SensorEntity] = [] for device in broker.devices.values(): @@ -641,7 +641,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): @property def name(self) -> str: - """Return the name of the binary sensor.""" + """Return the name of the sensor.""" return f"{self._device.label} {self._name}" @property @@ -681,7 +681,7 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): @property def name(self) -> str: - """Return the name of the binary sensor.""" + """Return the name of the sensor.""" return f"{self._device.label} {THREE_AXIS_NAMES[self._index]}" @property @@ -716,7 +716,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): @property def name(self) -> str: - """Return the name of the binary sensor.""" + """Return the name of the sensor.""" return f"{self._device.label} {self.report_name}" @property @@ -747,3 +747,19 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): if self.report_name == "power": return POWER_WATT return ENERGY_KILO_WATT_HOUR + + @property + def extra_state_attributes(self): + """Return specific state attributes.""" + if self.report_name == "power": + attributes = [ + "power_consumption_start", + "power_consumption_end", + ] + state_attributes = {} + for attribute in attributes: + value = getattr(self._device.status, attribute) + if value is not None: + state_attributes[attribute] = value + return state_attributes + return None diff --git a/homeassistant/components/smarttub/config_flow.py b/homeassistant/components/smarttub/config_flow.py index 88ec38e8d63..ec3dcb9c97f 100644 --- a/homeassistant/components/smarttub/config_flow.py +++ b/homeassistant/components/smarttub/config_flow.py @@ -1,9 +1,15 @@ """Config flow to configure the SmartTub integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + from smarttub import LoginFailed 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 .const import DOMAIN from .controller import SmartTubController @@ -21,8 +27,8 @@ class SmartTubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Instantiate config flow.""" super().__init__() - self._reauth_input = None - self._reauth_entry = None + self._reauth_input: Mapping[str, Any] | None = None + self._reauth_entry: config_entries.ConfigEntry | None = None async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" @@ -60,9 +66,9 @@ class SmartTubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Get new credentials if the current ones don't work anymore.""" - self._reauth_input = dict(user_input) + self._reauth_input = entry_data self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) diff --git a/homeassistant/components/smarttub/translations/bg.json b/homeassistant/components/smarttub/translations/bg.json index cef3726d759..ebfcda2158d 100644 --- a/homeassistant/components/smarttub/translations/bg.json +++ b/homeassistant/components/smarttub/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index f3e2c8bab2a..d9d757a71b5 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from pysmarty import Smarty + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -24,8 +26,8 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Smarty Binary Sensor Platform.""" - smarty = hass.data[DOMAIN]["api"] - name = hass.data[DOMAIN]["name"] + smarty: Smarty = hass.data[DOMAIN]["api"] + name: str = hass.data[DOMAIN]["name"] sensors = [ AlarmSensor(name, smarty), @@ -41,18 +43,23 @@ class SmartyBinarySensor(BinarySensorEntity): _attr_should_poll = False - def __init__(self, name, device_class, smarty): + def __init__( + self, + name: str, + device_class: BinarySensorDeviceClass | None, + smarty: Smarty, + ) -> None: """Initialize the entity.""" self._attr_name = name self._attr_device_class = device_class self._smarty = smarty - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" async_dispatcher_connect(self.hass, SIGNAL_UPDATE_SMARTY, self._update_callback) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @@ -60,7 +67,7 @@ class SmartyBinarySensor(BinarySensorEntity): class BoostSensor(SmartyBinarySensor): """Boost State Binary Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Alarm Sensor Init.""" super().__init__(name=f"{name} Boost State", device_class=None, smarty=smarty) @@ -73,7 +80,7 @@ class BoostSensor(SmartyBinarySensor): class AlarmSensor(SmartyBinarySensor): """Alarm Binary Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Alarm Sensor Init.""" super().__init__( name=f"{name} Alarm", @@ -90,7 +97,7 @@ class AlarmSensor(SmartyBinarySensor): class WarningSensor(SmartyBinarySensor): """Warning Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Warning Sensor Init.""" super().__init__( name=f"{name} Warning", diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 531b96d2558..cf4b49e6105 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -3,6 +3,9 @@ from __future__ import annotations import logging import math +from typing import Any + +from pysmarty import Smarty from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback @@ -31,8 +34,8 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Smarty Fan Platform.""" - smarty = hass.data[DOMAIN]["api"] - name = hass.data[DOMAIN]["name"] + smarty: Smarty = hass.data[DOMAIN]["api"] + name: str = hass.data[DOMAIN]["name"] async_add_entities([SmartyFan(name, smarty)], True) @@ -40,31 +43,18 @@ async def async_setup_platform( class SmartyFan(FanEntity): """Representation of a Smarty Fan.""" + _attr_icon = "mdi:air-conditioner" + _attr_should_poll = False _attr_supported_features = FanEntityFeature.SET_SPEED def __init__(self, name, smarty): """Initialize the entity.""" - self._name = name + self._attr_name = name self._smarty_fan_speed = 0 self._smarty = smarty @property - def should_poll(self): - """Do not poll.""" - return False - - @property - def name(self): - """Return the name of the fan.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:air-conditioner" - - @property - def is_on(self): + def is_on(self) -> bool: """Return state of the fan.""" return bool(self._smarty_fan_speed) @@ -96,12 +86,17 @@ class SmartyFan(FanEntity): self._smarty_fan_speed = fan_speed self.schedule_update_ha_state() - def turn_on(self, percentage=None, preset_mode=None, **kwargs): + def turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn on the fan.""" _LOGGER.debug("Turning on fan. percentage is %s", percentage) self.set_percentage(percentage or DEFAULT_ON_PERCENTAGE) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" _LOGGER.debug("Turning off fan") if not self._smarty.turn_off(): @@ -110,7 +105,7 @@ class SmartyFan(FanEntity): self._smarty_fan_speed = 0 self.schedule_update_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update fan.""" self.async_on_remove( async_dispatcher_connect( @@ -119,7 +114,7 @@ class SmartyFan(FanEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" _LOGGER.debug("Updating state") self._smarty_fan_speed = self._smarty.fan_speed diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 2ba5e81f286..1c76fe3bfb9 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations import datetime as dt import logging +from pysmarty import Smarty + from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback @@ -24,8 +26,8 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Smarty Sensor Platform.""" - smarty = hass.data[DOMAIN]["api"] - name = hass.data[DOMAIN]["name"] + smarty: Smarty = hass.data[DOMAIN]["api"] + name: str = hass.data[DOMAIN]["name"] sensors = [ SupplyAirTemperatureSensor(name, smarty), @@ -45,8 +47,12 @@ class SmartySensor(SensorEntity): _attr_should_poll = False def __init__( - self, name: str, device_class: str, smarty, unit_of_measurement: str = "" - ): + self, + name: str, + device_class: SensorDeviceClass | None, + smarty: Smarty, + unit_of_measurement: str | None, + ) -> None: """Initialize the entity.""" self._attr_name = name self._attr_native_value = None @@ -54,12 +60,12 @@ class SmartySensor(SensorEntity): self._attr_native_unit_of_measurement = unit_of_measurement self._smarty = smarty - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" async_dispatcher_connect(self.hass, SIGNAL_UPDATE_SMARTY, self._update_callback) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @@ -67,7 +73,7 @@ class SmartySensor(SensorEntity): class SupplyAirTemperatureSensor(SmartySensor): """Supply Air Temperature Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Supply Air Temperature Init.""" super().__init__( name=f"{name} Supply Air Temperature", @@ -85,7 +91,7 @@ class SupplyAirTemperatureSensor(SmartySensor): class ExtractAirTemperatureSensor(SmartySensor): """Extract Air Temperature Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Supply Air Temperature Init.""" super().__init__( name=f"{name} Extract Air Temperature", @@ -103,7 +109,7 @@ class ExtractAirTemperatureSensor(SmartySensor): class OutdoorAirTemperatureSensor(SmartySensor): """Extract Air Temperature Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Outdoor Air Temperature Init.""" super().__init__( name=f"{name} Outdoor Air Temperature", @@ -121,7 +127,7 @@ class OutdoorAirTemperatureSensor(SmartySensor): class SupplyFanSpeedSensor(SmartySensor): """Supply Fan Speed RPM.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Supply Fan Speed RPM Init.""" super().__init__( name=f"{name} Supply Fan Speed", @@ -139,7 +145,7 @@ class SupplyFanSpeedSensor(SmartySensor): class ExtractFanSpeedSensor(SmartySensor): """Extract Fan Speed RPM.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Extract Fan Speed RPM Init.""" super().__init__( name=f"{name} Extract Fan Speed", @@ -157,7 +163,7 @@ class ExtractFanSpeedSensor(SmartySensor): class FilterDaysLeftSensor(SmartySensor): """Filter Days Left.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Filter Days Left Init.""" super().__init__( name=f"{name} Filter Days Left", diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 398932bf4d6..98c8de87032 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,6 +1,12 @@ """Support for the Swedish weather institute weather service.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + Platform, +) from homeassistant.core import HomeAssistant PLATFORMS = [Platform.WEATHER] @@ -11,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Setting unique id where missing if entry.unique_id is None: - unique_id = f"{entry.data[CONF_LATITUDE]}-{entry.data[CONF_LONGITUDE]}" + unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" hass.config_entries.async_update_entry(entry, unique_id=unique_id) hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -21,3 +27,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1: + new_data = { + CONF_NAME: entry.data[CONF_NAME], + CONF_LOCATION: { + CONF_LATITUDE: entry.data[CONF_LATITUDE], + CONF_LONGITUDE: entry.data[CONF_LONGITUDE], + }, + } + + if not hass.config_entries.async_update_entry(entry, data=new_data): + return False + + entry.version = 2 + + return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 770f549efe0..bfa38f317a9 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -7,11 +7,11 @@ from smhi.smhi_lib import Smhi, SmhiForecastException 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_LOCATION, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import LocationSelector from .const import DEFAULT_NAME, DOMAIN, HOME_LOCATION_NAME @@ -33,7 +33,7 @@ async def async_check_location( class SmhiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for SMHI component.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -43,8 +43,8 @@ class SmhiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - lat: float = user_input[CONF_LATITUDE] - lon: float = user_input[CONF_LONGITUDE] + lat: float = user_input[CONF_LOCATION][CONF_LATITUDE] + lon: float = user_input[CONF_LOCATION][CONF_LONGITUDE] if await async_check_location(self.hass, lon, lat): name = f"{DEFAULT_NAME} {round(lat, 6)} {round(lon, 6)}" if ( @@ -63,24 +63,14 @@ class SmhiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "wrong_location" - default_lat: float = self.hass.config.latitude - default_lon: float = self.hass.config.longitude - - for entry in self.hass.config_entries.async_entries(DOMAIN): - if ( - entry.data[CONF_LATITUDE] == self.hass.config.latitude - and entry.data[CONF_LONGITUDE] == self.hass.config.longitude - ): - default_lat = 0 - default_lon = 0 - + home_location = { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } return self.async_show_form( step_id="user", data_schema=vol.Schema( - { - vol.Required(CONF_LATITUDE, default=default_lat): cv.latitude, - vol.Required(CONF_LONGITUDE, default=default_lon): cv.longitude, - } + {vol.Required(CONF_LOCATION, default=home_location): LocationSelector()} ), errors=errors, ) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index b63d32dc538..d7df54957c0 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from datetime import datetime, timedelta import logging -from typing import Final +from typing import Any, Final import aiohttp import async_timeout @@ -27,20 +28,27 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ROUNDING_PRECISION, Forecast, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, + CONF_LOCATION, CONF_LONGITUDE, CONF_NAME, LENGTH_KILOMETERS, LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -49,7 +57,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, slugify +from homeassistant.util import Throttle, slugify, speed as speed_util from .const import ( ATTR_SMHI_CLOUDINESS, @@ -99,8 +107,8 @@ async def async_setup_entry( entity = SmhiWeather( location[CONF_NAME], - location[CONF_LATITUDE], - location[CONF_LONGITUDE], + location[CONF_LOCATION][CONF_LATITUDE], + location[CONF_LOCATION][CONF_LONGITUDE], session=session, ) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) @@ -112,9 +120,11 @@ class SmhiWeather(WeatherEntity): """Representation of a weather entity.""" _attr_attribution = "Swedish weather institute (SMHI)" - _attr_temperature_unit = TEMP_CELSIUS - _attr_visibility_unit = LENGTH_KILOMETERS - _attr_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + _attr_native_pressure_unit = PRESSURE_HPA def __init__( self, @@ -139,7 +149,23 @@ class SmhiWeather(WeatherEntity): configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) self._attr_condition = None - self._attr_temperature = None + self._attr_native_temperature = None + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self._forecasts: + wind_gust = speed_util.convert( + self._forecasts[0].wind_gust, + SPEED_METERS_PER_SECOND, + self._wind_speed_unit, + ) + return { + ATTR_SMHI_CLOUDINESS: self._forecasts[0].cloudiness, + ATTR_SMHI_WIND_GUST_SPEED: round(wind_gust, ROUNDING_PRECISION), + ATTR_SMHI_THUNDER_PROBABILITY: self._forecasts[0].thunder, + } + return None @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: @@ -156,13 +182,12 @@ class SmhiWeather(WeatherEntity): return if self._forecasts: - self._attr_temperature = self._forecasts[0].temperature + self._attr_native_temperature = self._forecasts[0].temperature self._attr_humidity = self._forecasts[0].humidity - # Convert from m/s to km/h - self._attr_wind_speed = round(self._forecasts[0].wind_speed * 18 / 5) + self._attr_native_wind_speed = self._forecasts[0].wind_speed self._attr_wind_bearing = self._forecasts[0].wind_direction - self._attr_visibility = self._forecasts[0].horizontal_visibility - self._attr_pressure = self._forecasts[0].pressure + self._attr_native_visibility = self._forecasts[0].horizontal_visibility + self._attr_native_pressure = self._forecasts[0].pressure self._attr_condition = next( ( k @@ -171,12 +196,6 @@ class SmhiWeather(WeatherEntity): ), None, ) - self._attr_extra_state_attributes = { - ATTR_SMHI_CLOUDINESS: self._forecasts[0].cloudiness, - # Convert from m/s to km/h - ATTR_SMHI_WIND_GUST_SPEED: round(self._forecasts[0].wind_gust * 18 / 5), - ATTR_SMHI_THUNDER_PROBABILITY: self._forecasts[0].thunder, - } async def retry_update(self, _: datetime) -> None: """Retry refresh weather forecast.""" @@ -200,10 +219,13 @@ class SmhiWeather(WeatherEntity): data.append( { ATTR_FORECAST_TIME: forecast.valid_time.isoformat(), - ATTR_FORECAST_TEMP: forecast.temperature_max, - ATTR_FORECAST_TEMP_LOW: forecast.temperature_min, - ATTR_FORECAST_PRECIPITATION: round(forecast.total_precipitation, 1), + ATTR_FORECAST_NATIVE_TEMP: forecast.temperature_max, + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast.temperature_min, + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.total_precipitation, ATTR_FORECAST_CONDITION: condition, + ATTR_FORECAST_NATIVE_PRESSURE: forecast.pressure, + ATTR_FORECAST_WIND_BEARING: forecast.wind_direction, + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast.wind_speed, } ) diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index b1c2703409c..0b63a3d0366 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -1,6 +1,9 @@ """The sms component.""" +from datetime import timedelta import logging +import async_timeout +import gammu # pylint: disable=import-error import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -8,8 +11,18 @@ from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_BAUD_SPEED, DEFAULT_BAUD_SPEED, DOMAIN, SMS_GATEWAY +from .const import ( + CONF_BAUD_SPEED, + DEFAULT_BAUD_SPEED, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + GATEWAY, + NETWORK_COORDINATOR, + SIGNAL_COORDINATOR, + SMS_GATEWAY, +) from .gateway import create_sms_gateway _LOGGER = logging.getLogger(__name__) @@ -30,6 +43,8 @@ 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.""" @@ -61,7 +76,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway = await create_sms_gateway(config, hass) if not gateway: return False - hass.data[DOMAIN][SMS_GATEWAY] = gateway + + signal_coordinator = SignalCoordinator(hass, gateway) + network_coordinator = NetworkCoordinator(hass, gateway) + + # Fetch initial data so we have data when entities subscribe + await signal_coordinator.async_config_entry_first_refresh() + await network_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][SMS_GATEWAY] = { + SIGNAL_COORDINATOR: signal_coordinator, + NETWORK_COORDINATOR: network_coordinator, + GATEWAY: gateway, + } + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -71,7 +100,51 @@ 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) if unload_ok: - gateway = hass.data[DOMAIN].pop(SMS_GATEWAY) + gateway = hass.data[DOMAIN].pop(SMS_GATEWAY)[GATEWAY] 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 async_timeout.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 async_timeout.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/const.py b/homeassistant/components/sms/const.py index 7c40a04073c..858e53d9808 100644 --- a/homeassistant/components/sms/const.py +++ b/homeassistant/components/sms/const.py @@ -1,8 +1,17 @@ """Constants for sms Component.""" +from typing import Final + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS +from homeassistant.helpers.entity import EntityCategory DOMAIN = "sms" SMS_GATEWAY = "SMS_GATEWAY" SMS_STATE_UNREAD = "UnRead" +SIGNAL_COORDINATOR = "signal_coordinator" +NETWORK_COORDINATOR = "network_coordinator" +GATEWAY = "gateway" +DEFAULT_SCAN_INTERVAL = 30 CONF_BAUD_SPEED = "baud_speed" DEFAULT_BAUD_SPEED = "0" DEFAULT_BAUD_SPEEDS = [ @@ -27,3 +36,61 @@ DEFAULT_BAUD_SPEEDS = [ {"value": "76800", "label": "76800"}, {"value": "115200", "label": "115200"}, ] + +SIGNAL_SENSORS: Final[dict[str, SensorEntityDescription]] = { + "SignalStrength": SensorEntityDescription( + key="SignalStrength", + name="Signal Strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, + ), + "SignalPercent": SensorEntityDescription( + key="SignalPercent", + icon="mdi:signal-cellular-3", + name="Signal Percent", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=True, + ), + "BitErrorRate": SensorEntityDescription( + key="BitErrorRate", + name="Bit Error Rate", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), +} + +NETWORK_SENSORS: Final[dict[str, SensorEntityDescription]] = { + "NetworkName": SensorEntityDescription( + key="NetworkName", + name="Network Name", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "State": SensorEntityDescription( + key="State", + name="Network Status", + entity_registry_enabled_default=True, + ), + "NetworkCode": SensorEntityDescription( + key="NetworkCode", + name="GSM network code", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "CID": SensorEntityDescription( + key="CID", + name="Cell ID", + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "LAC": SensorEntityDescription( + key="LAC", + name="Local Area Code", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +} diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 09992600943..c469e688737 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -154,6 +154,10 @@ class Gateway: """Get the current signal level of the modem.""" return await self._worker.get_signal_quality_async() + async def get_network_info_async(self): + """Get the current network info of the modem.""" + return await self._worker.get_network_info_async() + async def terminate_async(self): """Terminate modem connection.""" return await self._worker.terminate_async() diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py index 1bd3c60b9b9..d076f3625ba 100644 --- a/homeassistant/components/sms/notify.py +++ b/homeassistant/components/sms/notify.py @@ -5,10 +5,10 @@ import gammu # pylint: disable=import-error import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -from homeassistant.const import CONF_NAME, CONF_RECIPIENT +from homeassistant.const import CONF_NAME, CONF_RECIPIENT, CONF_TARGET import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, SMS_GATEWAY +from .const import DOMAIN, GATEWAY, SMS_GATEWAY _LOGGER = logging.getLogger(__name__) @@ -24,7 +24,7 @@ def get_service(hass, config, discovery_info=None): _LOGGER.error("SMS gateway not found, cannot initialize service") return - gateway = hass.data[DOMAIN][SMS_GATEWAY] + gateway = hass.data[DOMAIN][SMS_GATEWAY][GATEWAY] if discovery_info is None: number = config[CONF_RECIPIENT] @@ -44,6 +44,8 @@ class SMSNotificationService(BaseNotificationService): async def async_send_message(self, message="", **kwargs): """Send SMS message.""" + + targets = kwargs.get(CONF_TARGET, [self.number]) smsinfo = { "Class": -1, "Unicode": True, @@ -60,9 +62,11 @@ class SMSNotificationService(BaseNotificationService): for encoded_message in encoded: # Fill in numbers encoded_message["SMSC"] = {"Location": 1} - encoded_message["Number"] = self.number - try: - # Actually send the message - await self.gateway.send_sms_async(encoded_message) - except gammu.GSMError as exc: - _LOGGER.error("Sending to %s failed: %s", self.number, exc) + + for target in targets: + encoded_message["Number"] = target + try: + # Actually send the message + await self.gateway.send_sms_async(encoded_message) + except gammu.GSMError as exc: + _LOGGER.error("Sending to %s failed: %s", target, exc) diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index dcc85c4f8c6..de20a5b5d0f 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -1,22 +1,20 @@ """Support for SMS dongle sensor.""" -import logging - -import gammu # pylint: disable=import-error - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, -) +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import SIGNAL_STRENGTH_DECIBELS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SMS_GATEWAY - -_LOGGER = logging.getLogger(__name__) +from .const import ( + DOMAIN, + GATEWAY, + NETWORK_COORDINATOR, + NETWORK_SENSORS, + SIGNAL_COORDINATOR, + SIGNAL_SENSORS, + SMS_GATEWAY, +) async def async_setup_entry( @@ -24,61 +22,46 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the GSM Signal Sensor sensor.""" - gateway = hass.data[DOMAIN][SMS_GATEWAY] - imei = await gateway.get_imei_async() - async_add_entities( - [ - GSMSignalSensor( - hass, - gateway, - imei, - SensorEntityDescription( - key="signal", - name=f"gsm_signal_imei_{imei}", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - entity_registry_enabled_default=False, - ), + """Set up all device sensors.""" + sms_data = hass.data[DOMAIN][SMS_GATEWAY] + signal_coordinator = sms_data[SIGNAL_COORDINATOR] + network_coordinator = sms_data[NETWORK_COORDINATOR] + gateway = sms_data[GATEWAY] + unique_id = str(await gateway.get_imei_async()) + entities = [] + for description in SIGNAL_SENSORS.values(): + entities.append( + DeviceSensor( + signal_coordinator, + description, + unique_id, ) - ], - True, - ) + ) + for description in NETWORK_SENSORS.values(): + entities.append( + DeviceSensor( + network_coordinator, + description, + unique_id, + ) + ) + async_add_entities(entities, True) -class GSMSignalSensor(SensorEntity): - """Implementation of a GSM Signal sensor.""" +class DeviceSensor(CoordinatorEntity, SensorEntity): + """Implementation of a device sensor.""" - def __init__(self, hass, gateway, imei, description): - """Initialize the GSM Signal sensor.""" + def __init__(self, coordinator, description, unique_id): + """Initialize the device sensor.""" + super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(imei))}, + identifiers={(DOMAIN, unique_id)}, name="SMS Gateway", ) - self._attr_unique_id = str(imei) - self._hass = hass - self._gateway = gateway - self._state = None + self._attr_unique_id = f"{unique_id}_{description.key}" self.entity_description = description - @property - def available(self): - """Return if the sensor data are available.""" - return self._state is not None - @property def native_value(self): """Return the state of the device.""" - return self._state["SignalStrength"] - - async def async_update(self): - """Get the latest data from the modem.""" - try: - self._state = await self._gateway.get_signal_quality_async() - except gammu.GSMError as exc: - _LOGGER.error("Failed to read signal quality: %s", exc) - - @property - def extra_state_attributes(self): - """Return the sensor attributes.""" - return self._state + return self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 76df9e18606..1ffcb04ebda 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -2,7 +2,7 @@ "domain": "snmp", "name": "SNMP", "documentation": "https://www.home-assistant.io/integrations/snmp", - "requirements": ["pysnmp==4.4.12"], + "requirements": ["pysnmplib==5.0.15"], "codeowners": [], "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"] diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index ba111ffc9bc..11f8c7d2f64 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -17,12 +17,11 @@ from pysnmp.hlapi.asyncio import ( ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, - CONF_NAME, CONF_PORT, - CONF_UNIT_OF_MEASUREMENT, + CONF_UNIQUE_ID, CONF_USERNAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, @@ -30,6 +29,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import ( + TEMPLATE_SENSOR_BASE_SCHEMA, + TemplateSensor, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -66,9 +69,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, vol.Optional(CONF_DEFAULT_VALUE): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), vol.Optional(CONF_USERNAME): cv.string, @@ -81,7 +82,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( MAP_PRIV_PROTOCOLS ), } -) +).extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) async def async_setup_platform( @@ -91,12 +92,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP sensor.""" - name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) baseoid = config.get(CONF_BASEOID) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) version = config[CONF_VERSION] username = config.get(CONF_USERNAME) authkey = config.get(CONF_AUTH_KEY) @@ -105,10 +104,7 @@ async def async_setup_platform( privproto = config[CONF_PRIV_PROTOCOL] accept_errors = config.get(CONF_ACCEPT_ERRORS) default_value = config.get(CONF_DEFAULT_VALUE) - value_template = config.get(CONF_VALUE_TEMPLATE) - - if value_template is not None: - value_template.hass = hass + unique_id = config.get(CONF_UNIQUE_ID) if version == "3": @@ -146,35 +142,30 @@ async def async_setup_platform( return data = SnmpData(request_args, baseoid, accept_errors, default_value) - async_add_entities([SnmpSensor(data, name, unit, value_template)], True) + async_add_entities([SnmpSensor(hass, data, config, unique_id)], True) -class SnmpSensor(SensorEntity): +class SnmpSensor(TemplateSensor): """Representation of a SNMP sensor.""" - def __init__(self, data, name, unit_of_measurement, value_template): - """Initialize the sensor.""" - self.data = data - self._name = name - self._state = None - self._unit_of_measurement = unit_of_measurement - self._value_template = value_template + _attr_should_poll = True - @property - def name(self): - """Return the name of the sensor.""" - return self._name + def __init__(self, hass, data, config, unique_id): + """Initialize the sensor.""" + super().__init__( + hass, config=config, unique_id=unique_id, fallback_name=DEFAULT_NAME + ) + self.data = data + self._state = None + self._value_template = config.get(CONF_VALUE_TEMPLATE) + if (value_template := self._value_template) is not None: + value_template.hass = hass @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest data and updates the states.""" await self.data.async_update() diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index 4e93571f8a4..fe8f2f86a8e 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -94,8 +94,10 @@ class SolarEdgeOverviewDataService(SolarEdgeDataService): for index, key in enumerate(energy_keys, start=1): # All coming values in list should be larger than the current value. if any(self.data[k] > self.data[key] for k in energy_keys[index:]): - self.data = {} - raise UpdateFailed("Invalid energy values, skipping update") + LOGGER.info( + "Ignoring invalid energy value %s for %s", self.data[key], key + ) + self.data.pop(key) LOGGER.debug("Updated SolarEdge overview: %s", self.data) diff --git a/homeassistant/components/solaredge/translations/sv.json b/homeassistant/components/solaredge/translations/sv.json index f09320388f2..df7408b43c2 100644 --- a/homeassistant/components/solaredge/translations/sv.json +++ b/homeassistant/components/solaredge/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_api_key": "Ogiltig API-nyckel" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/solax/translations/es.json b/homeassistant/components/solax/translations/es.json index f6658c63353..da4765b897d 100644 --- a/homeassistant/components/solax/translations/es.json +++ b/homeassistant/components/solax/translations/es.json @@ -7,6 +7,8 @@ "step": { "user": { "data": { + "ip_address": "Direcci\u00f3n IP", + "password": "Contrase\u00f1a", "port": "Puerto" } } diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index abc6d828acc..116f88aa20e 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -1,6 +1,8 @@ """Support for Soma Covers.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -50,16 +52,16 @@ class SomaTilt(SomaEntity, CoverEntity): ) @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int: """Return the current cover tilt position.""" return self.current_position @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover tilt is closed.""" return self.current_position == 0 - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): @@ -68,7 +70,7 @@ class SomaTilt(SomaEntity, CoverEntity): ) self.set_position(0) - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" response = self.api.set_shade_position(self.device["mac"], -100) if not is_api_response_success(response): @@ -77,7 +79,7 @@ class SomaTilt(SomaEntity, CoverEntity): ) self.set_position(100) - def stop_cover_tilt(self, **kwargs): + def stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): @@ -87,7 +89,7 @@ class SomaTilt(SomaEntity, CoverEntity): # Set cover position to some value where up/down are both enabled self.set_position(50) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" # 0 -> Closed down (api: 100) # 50 -> Fully open (api: 0) @@ -100,7 +102,7 @@ class SomaTilt(SomaEntity, CoverEntity): ) self.set_position(kwargs[ATTR_TILT_POSITION]) - async def async_update(self): + async def async_update(self) -> None: """Update the entity with the latest data.""" response = await self.get_shade_state_from_api() @@ -124,16 +126,16 @@ class SomaShade(SomaEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current cover position.""" return self.current_position @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.current_position == 0 - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): @@ -141,7 +143,7 @@ class SomaShade(SomaEntity, CoverEntity): f'Error while closing the cover ({self.name}): {response["msg"]}' ) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" response = self.api.set_shade_position(self.device["mac"], 0) if not is_api_response_success(response): @@ -149,7 +151,7 @@ class SomaShade(SomaEntity, CoverEntity): f'Error while opening the cover ({self.name}): {response["msg"]}' ) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): @@ -159,7 +161,7 @@ class SomaShade(SomaEntity, CoverEntity): # Set cover position to some value where up/down are both enabled self.set_position(50) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" self.current_position = kwargs[ATTR_POSITION] response = self.api.set_shade_position( @@ -170,7 +172,7 @@ class SomaShade(SomaEntity, CoverEntity): f'Error while setting the cover position ({self.name}): {response["msg"]}' ) - async def async_update(self): + async def async_update(self) -> None: """Update the cover with the latest data.""" response = await self.get_shade_state_from_api() diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py deleted file mode 100644 index ed6c58bc0a0..00000000000 --- a/homeassistant/components/somfy/__init__.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Support for Somfy hubs.""" -from datetime import timedelta -import logging - -from pymfy.api.devices.category import Category -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_OPTIMISTIC, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - device_registry as dr, -) -from homeassistant.helpers.typing import ConfigType - -from . import api, config_flow -from .const import COORDINATOR, DOMAIN -from .coordinator import SomfyDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=1) -SCAN_INTERVAL_ALL_ASSUMED_STATE = timedelta(minutes=60) - -SOMFY_AUTH_CALLBACK_PATH = "/auth/somfy/callback" -SOMFY_AUTH_START = "/auth/somfy" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Inclusive(CONF_CLIENT_ID, "oauth"): cv.string, - vol.Inclusive(CONF_CLIENT_SECRET, "oauth"): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -PLATFORMS = [ - Platform.CLIMATE, - Platform.COVER, - Platform.SENSOR, - Platform.SWITCH, -] - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Somfy component.""" - hass.data[DOMAIN] = {} - domain_config = config.get(DOMAIN, {}) - hass.data[DOMAIN][CONF_OPTIMISTIC] = domain_config.get(CONF_OPTIMISTIC, False) - - if CONF_CLIENT_ID in domain_config: - config_flow.SomfyFlowHandler.async_register_implementation( - hass, - config_entry_oauth2_flow.LocalOAuth2Implementation( - hass, - DOMAIN, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - "https://accounts.somfy.com/oauth/oauth/v2/auth", - "https://accounts.somfy.com/oauth/oauth/v2/token", - ), - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Somfy from a config entry.""" - - _LOGGER.warning( - "The Somfy integration is deprecated and will be removed " - "in Home Assistant Core 2022.7; due to the Somfy Open API deprecation." - "The Somfy Open API will shutdown June 21st 2022, migrate to the " - "Overkiz integration to control your Somfy devices" - ) - - # Backwards compat - if "auth_implementation" not in entry.data: - hass.config_entries.async_update_entry( - entry, data={**entry.data, "auth_implementation": DOMAIN} - ) - - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) - - data = hass.data[DOMAIN] - coordinator = SomfyDataUpdateCoordinator( - hass, - _LOGGER, - name="somfy device update", - client=api.ConfigEntrySomfyApi(hass, entry, implementation), - update_interval=SCAN_INTERVAL, - ) - data[COORDINATOR] = coordinator - - await coordinator.async_config_entry_first_refresh() - - if all(not bool(device.states) for device in coordinator.data.values()): - _LOGGER.debug( - "All devices have assumed state. Update interval has been reduced to: %s", - SCAN_INTERVAL_ALL_ASSUMED_STATE, - ) - coordinator.update_interval = SCAN_INTERVAL_ALL_ASSUMED_STATE - - device_registry = dr.async_get(hass) - - hubs = [ - device - for device in coordinator.data.values() - if Category.HUB.value in device.categories - ] - - for hub in hubs: - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, hub.id)}, - manufacturer="Somfy", - name=hub.name, - model=hub.type, - ) - - hass.config_entries.async_setup_platforms(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/somfy/api.py b/homeassistant/components/somfy/api.py deleted file mode 100644 index bfa834cddb2..00000000000 --- a/homeassistant/components/somfy/api.py +++ /dev/null @@ -1,37 +0,0 @@ -"""API for Somfy bound to Home Assistant OAuth.""" -from __future__ import annotations - -from asyncio import run_coroutine_threadsafe - -from pymfy.api import somfy_api - -from homeassistant import config_entries, core -from homeassistant.helpers import config_entry_oauth2_flow - - -class ConfigEntrySomfyApi(somfy_api.SomfyApi): - """Provide a Somfy API tied into an OAuth2 based config entry.""" - - def __init__( - self, - hass: core.HomeAssistant, - config_entry: config_entries.ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, - ) -> None: - """Initialize the Config Entry Somfy API.""" - self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - super().__init__(None, None, token=self.session.token) - - def refresh_tokens( - self, - ) -> dict[str, str | int]: - """Refresh and return new Somfy tokens using Home Assistant OAuth2 session.""" - run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self.hass.loop - ).result() - - return self.session.token diff --git a/homeassistant/components/somfy/climate.py b/homeassistant/components/somfy/climate.py deleted file mode 100644 index 1384574c3d3..00000000000 --- a/homeassistant/components/somfy/climate.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Support for Somfy Thermostat.""" -from __future__ import annotations - -from pymfy.api.devices.category import Category -from pymfy.api.devices.thermostat import ( - DurationType, - HvacState, - RegulationState, - TargetMode, - Thermostat, -) - -from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import ( - PRESET_AWAY, - PRESET_HOME, - PRESET_SLEEP, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import COORDINATOR, DOMAIN -from .entity import SomfyEntity - -SUPPORTED_CATEGORIES = {Category.HVAC.value} - -PRESET_FROST_GUARD = "Frost Guard" -PRESET_GEOFENCING = "Geofencing" -PRESET_MANUAL = "Manual" - -PRESETS_MAPPING = { - TargetMode.AT_HOME: PRESET_HOME, - TargetMode.AWAY: PRESET_AWAY, - TargetMode.SLEEP: PRESET_SLEEP, - TargetMode.MANUAL: PRESET_MANUAL, - TargetMode.GEOFENCING: PRESET_GEOFENCING, - TargetMode.FROST_PROTECTION: PRESET_FROST_GUARD, -} -REVERSE_PRESET_MAPPING = {v: k for k, v in PRESETS_MAPPING.items()} - -HVAC_MODES_MAPPING = {HvacState.COOL: HVACMode.COOL, HvacState.HEAT: HVACMode.HEAT} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy climate platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - climates = [ - SomfyClimate(coordinator, device_id) - for device_id, device in coordinator.data.items() - if SUPPORTED_CATEGORIES & set(device.categories) - ] - - async_add_entities(climates) - - -class SomfyClimate(SomfyEntity, ClimateEntity): - """Representation of a Somfy thermostat device.""" - - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self._climate = None - self._create_device() - - def _create_device(self): - """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.coordinator.client) - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._climate.get_ambient_temperature() - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._climate.get_target_temperature() - - def set_temperature(self, **kwargs) -> None: - """Set new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return - - self._climate.set_target(TargetMode.MANUAL, temperature, DurationType.NEXT_MODE) - - @property - def max_temp(self) -> float: - """Return the maximum temperature.""" - return 26.0 - - @property - def min_temp(self) -> float: - """Return the minimum temperature.""" - return 15.0 - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._climate.get_humidity() - - @property - def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode.""" - if self._climate.get_regulation_state() == RegulationState.TIMETABLE: - return HVACMode.AUTO - return HVAC_MODES_MAPPING[self._climate.get_hvac_state()] - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - HEAT and COOL mode are exclusive. End user has to enable a mode manually within the Somfy application. - So only one mode can be displayed. Auto mode is a scheduler. - """ - hvac_state = HVAC_MODES_MAPPING[self._climate.get_hvac_state()] - return [HVACMode.AUTO, hvac_state] - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - if hvac_mode == HVACMode.AUTO: - self._climate.cancel_target() - else: - self._climate.set_target( - TargetMode.MANUAL, self.target_temperature, DurationType.FURTHER_NOTICE - ) - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode.""" - mode = self._climate.get_target_mode() - return PRESETS_MAPPING.get(mode) - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return list(PRESETS_MAPPING.values()) - - def set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" - if self.preset_mode == preset_mode: - return - - if preset_mode == PRESET_HOME: - temperature = self._climate.get_at_home_temperature() - elif preset_mode == PRESET_AWAY: - temperature = self._climate.get_away_temperature() - elif preset_mode == PRESET_SLEEP: - temperature = self._climate.get_night_temperature() - elif preset_mode == PRESET_FROST_GUARD: - temperature = self._climate.get_frost_protection_temperature() - elif preset_mode in (PRESET_MANUAL, PRESET_GEOFENCING): - temperature = self.target_temperature - else: - raise ValueError(f"Preset mode not supported: {preset_mode}") - - self._climate.set_target( - REVERSE_PRESET_MAPPING[preset_mode], temperature, DurationType.NEXT_MODE - ) diff --git a/homeassistant/components/somfy/config_flow.py b/homeassistant/components/somfy/config_flow.py deleted file mode 100644 index 05d1720cf6d..00000000000 --- a/homeassistant/components/somfy/config_flow.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Config flow for Somfy.""" -import logging - -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import DOMAIN - - -class SomfyFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN -): - """Config flow to handle Somfy OAuth2 authentication.""" - - DOMAIN = DOMAIN - - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - - async def async_step_user(self, user_input=None): - """Handle a flow start.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - return await super().async_step_user(user_input) diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py deleted file mode 100644 index 6c7c23e3ab3..00000000000 --- a/homeassistant/components/somfy/const.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Define constants for the Somfy component.""" - -DOMAIN = "somfy" -COORDINATOR = "coordinator" diff --git a/homeassistant/components/somfy/coordinator.py b/homeassistant/components/somfy/coordinator.py deleted file mode 100644 index a22f8185702..00000000000 --- a/homeassistant/components/somfy/coordinator.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Helpers to help coordinate updated.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -from pymfy.api.error import QuotaViolationException, SetupNotFoundException -from pymfy.api.model import Device -from pymfy.api.somfy_api import SomfyApi - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -class SomfyDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Somfy data.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - *, - name: str, - client: SomfyApi, - update_interval: timedelta | None = None, - ) -> None: - """Initialize global data updater.""" - super().__init__( - hass, - logger, - name=name, - update_interval=update_interval, - ) - self.data = {} - self.client = client - self.site_device: dict[str, list] = {} - self.last_site_index = -1 - - async def _async_update_data(self) -> dict[str, Device]: - """Fetch Somfy data. - - Somfy only allow one call per minute to /site. There is one exception: 2 calls are allowed after site retrieval. - """ - if not self.site_device: - sites = await self.hass.async_add_executor_job(self.client.get_sites) - if not sites: - return {} - self.site_device = {site.id: [] for site in sites} - - site_id = self._site_id - try: - devices = await self.hass.async_add_executor_job( - self.client.get_devices, site_id - ) - self.site_device[site_id] = devices - except SetupNotFoundException: - del self.site_device[site_id] - return await self._async_update_data() - except QuotaViolationException: - self.logger.warning("Quota violation") - - return {dev.id: dev for devices in self.site_device.values() for dev in devices} - - @property - def _site_id(self): - """Return the next site id to retrieve. - - This tweak is required as Somfy does not allow to call the /site entrypoint more than once per minute. - """ - self.last_site_index = (self.last_site_index + 1) % len(self.site_device) - return list(self.site_device.keys())[self.last_site_index] diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py deleted file mode 100644 index a2a72d4ce98..00000000000 --- a/homeassistant/components/somfy/cover.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Support for Somfy Covers.""" -from __future__ import annotations - -from typing import cast - -from pymfy.api.devices.blind import Blind -from pymfy.api.devices.category import Category - -from homeassistant.components.cover import ( - ATTR_POSITION, - ATTR_TILT_POSITION, - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_OPTIMISTIC, STATE_CLOSED, STATE_OPEN -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -from .const import COORDINATOR, DOMAIN -from .coordinator import SomfyDataUpdateCoordinator -from .entity import SomfyEntity - -BLIND_DEVICE_CATEGORIES = {Category.INTERIOR_BLIND.value, Category.EXTERIOR_BLIND.value} -SHUTTER_DEVICE_CATEGORIES = {Category.EXTERIOR_BLIND.value} -SUPPORTED_CATEGORIES = { - Category.ROLLER_SHUTTER.value, - Category.INTERIOR_BLIND.value, - Category.EXTERIOR_BLIND.value, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy cover platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - covers = [ - SomfyCover(coordinator, device_id, domain_data[CONF_OPTIMISTIC]) - for device_id, device in coordinator.data.items() - if SUPPORTED_CATEGORIES & set(device.categories) - ] - - async_add_entities(covers) - - -class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity): - """Representation of a Somfy cover device.""" - - def __init__(self, coordinator, device_id, optimistic): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self.categories = set(self.device.categories) - self.optimistic = optimistic - self._closed = None - self._is_opening = None - self._is_closing = None - self._cover = None - self._create_device() - - def _create_device(self) -> Blind: - """Update the device with the latest data.""" - self._cover = Blind( - self.device, cast(SomfyDataUpdateCoordinator, self.coordinator).client - ) - - @property - def supported_features(self) -> int: - """Flag supported features.""" - supported_features = 0 - if self.has_capability("open"): - supported_features |= CoverEntityFeature.OPEN - if self.has_capability("close"): - supported_features |= CoverEntityFeature.CLOSE - if self.has_capability("stop"): - supported_features |= CoverEntityFeature.STOP - if self.has_capability("position"): - supported_features |= CoverEntityFeature.SET_POSITION - if self.has_capability("rotation"): - supported_features |= ( - CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.STOP_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) - - return supported_features - - async def async_close_cover(self, **kwargs): - """Close the cover.""" - self._is_closing = True - self.async_write_ha_state() - try: - # Blocks until the close command is sent - await self.hass.async_add_executor_job(self._cover.close) - self._closed = True - finally: - self._is_closing = None - self.async_write_ha_state() - - async def async_open_cover(self, **kwargs): - """Open the cover.""" - self._is_opening = True - self.async_write_ha_state() - try: - # Blocks until the open command is sent - await self.hass.async_add_executor_job(self._cover.open) - self._closed = False - finally: - self._is_opening = None - self.async_write_ha_state() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self._cover.stop() - - def set_cover_position(self, **kwargs): - """Move the cover shutter to a specific position.""" - self._cover.set_position(100 - kwargs[ATTR_POSITION]) - - @property - def device_class(self): - """Return the device class.""" - if self.categories & BLIND_DEVICE_CATEGORIES: - return CoverDeviceClass.BLIND - if self.categories & SHUTTER_DEVICE_CATEGORIES: - return CoverDeviceClass.SHUTTER - return None - - @property - def current_cover_position(self): - """Return the current position of cover shutter.""" - if not self.has_state("position"): - return None - return 100 - self._cover.get_position() - - @property - def is_opening(self): - """Return if the cover is opening.""" - if not self.optimistic: - return None - return self._is_opening - - @property - def is_closing(self): - """Return if the cover is closing.""" - if not self.optimistic: - return None - return self._is_closing - - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed.""" - is_closed = None - if self.has_state("position"): - is_closed = self._cover.is_closed() - elif self.optimistic: - is_closed = self._closed - return is_closed - - @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt. - - None is unknown, 0 is closed, 100 is fully open. - """ - if not self.has_state("orientation"): - return None - return 100 - self._cover.orientation - - def set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - self._cover.orientation = 100 - kwargs[ATTR_TILT_POSITION] - - def open_cover_tilt(self, **kwargs): - """Open the cover tilt.""" - self._cover.orientation = 0 - - def close_cover_tilt(self, **kwargs): - """Close the cover tilt.""" - self._cover.orientation = 100 - - def stop_cover_tilt(self, **kwargs): - """Stop the cover.""" - self._cover.stop() - - async def async_added_to_hass(self): - """Complete the initialization.""" - await super().async_added_to_hass() - if not self.optimistic: - return - # Restore the last state if we use optimistic - last_state = await self.async_get_last_state() - - if last_state is not None and last_state.state in ( - STATE_OPEN, - STATE_CLOSED, - ): - self._closed = last_state.state == STATE_CLOSED diff --git a/homeassistant/components/somfy/entity.py b/homeassistant/components/somfy/entity.py deleted file mode 100644 index 2d92c8a77c0..00000000000 --- a/homeassistant/components/somfy/entity.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Entity representing a Somfy device.""" - -from abc import abstractmethod - -from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN - - -class SomfyEntity(CoordinatorEntity, Entity): - """Representation of a generic Somfy device.""" - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator) - self._id = device_id - - @property - def device(self): - """Return data for the device id.""" - return self.coordinator.data[self._id] - - @property - def unique_id(self) -> str: - """Return the unique id base on the id returned by Somfy.""" - return self._id - - @property - def name(self) -> str: - """Return the name of the device.""" - return self.device.name - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes. - - Implemented by platform classes. - """ - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=self.name, - model=self.device.type, - via_device=(DOMAIN, self.device.parent_id), - # For the moment, Somfy only returns their own device. - manufacturer="Somfy", - ) - - def has_capability(self, capability: str) -> bool: - """Test if device has a capability.""" - capabilities = self.device.capabilities - return bool([c for c in capabilities if c.name == capability]) - - def has_state(self, state: str) -> bool: - """Test if device has a state.""" - states = self.device.states - return bool([c for c in states if c.name == state]) - - @property - def assumed_state(self) -> bool: - """Return if the device has an assumed state.""" - return not bool(self.device.states) - - @callback - def _handle_coordinator_update(self): - """Process an update from the coordinator.""" - self._create_device() - super()._handle_coordinator_update() - - @abstractmethod - def _create_device(self): - """Update the device with the latest data.""" diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json deleted file mode 100644 index 76e3d281228..00000000000 --- a/homeassistant/components/somfy/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "domain": "somfy", - "name": "Somfy", - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/somfy", - "dependencies": ["auth"], - "codeowners": ["@tetienne"], - "requirements": ["pymfy==0.11.0"], - "zeroconf": [ - { - "type": "_kizbox._tcp.local.", - "name": "gateway*" - } - ], - "iot_class": "cloud_polling", - "loggers": ["pymfy"] -} diff --git a/homeassistant/components/somfy/sensor.py b/homeassistant/components/somfy/sensor.py deleted file mode 100644 index 6dcc45b78a5..00000000000 --- a/homeassistant/components/somfy/sensor.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Support for Somfy Thermostat Battery.""" -from pymfy.api.devices.category import Category -from pymfy.api.devices.thermostat import Thermostat - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import COORDINATOR, DOMAIN -from .entity import SomfyEntity - -SUPPORTED_CATEGORIES = {Category.HVAC.value} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy sensor platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - sensors = [ - SomfyThermostatBatterySensor(coordinator, device_id) - for device_id, device in coordinator.data.items() - if SUPPORTED_CATEGORIES & set(device.categories) - ] - - async_add_entities(sensors) - - -class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): - """Representation of a Somfy thermostat battery.""" - - _attr_device_class = SensorDeviceClass.BATTERY - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self._climate = None - self._create_device() - - def _create_device(self): - """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.coordinator.client) - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return self._climate.get_battery() diff --git a/homeassistant/components/somfy/strings.json b/homeassistant/components/somfy/strings.json deleted file mode 100644 index 85ef981e356..00000000000 --- a/homeassistant/components/somfy/strings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - } - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - } - } -} diff --git a/homeassistant/components/somfy/switch.py b/homeassistant/components/somfy/switch.py deleted file mode 100644 index d86fc051a14..00000000000 --- a/homeassistant/components/somfy/switch.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Support for Somfy Camera Shutter.""" -from pymfy.api.devices.camera_protect import CameraProtect -from pymfy.api.devices.category import Category - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import COORDINATOR, DOMAIN -from .entity import SomfyEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy switch platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - switches = [ - SomfyCameraShutter(coordinator, device_id) - for device_id, device in coordinator.data.items() - if Category.CAMERA.value in device.categories - ] - - async_add_entities(switches) - - -class SomfyCameraShutter(SomfyEntity, SwitchEntity): - """Representation of a Somfy Camera Shutter device.""" - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self._create_device() - - def _create_device(self): - """Update the device with the latest data.""" - self.shutter = CameraProtect(self.device, self.coordinator.client) - - def turn_on(self, **kwargs) -> None: - """Turn the entity on.""" - self.shutter.open_shutter() - - def turn_off(self, **kwargs): - """Turn the entity off.""" - self.shutter.close_shutter() - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self.shutter.get_shutter_position() == "opened" diff --git a/homeassistant/components/somfy/translations/bg.json b/homeassistant/components/somfy/translations/bg.json deleted file mode 100644 index 62905ef389e..00000000000 --- a/homeassistant/components/somfy/translations/bg.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0434\u0440\u0435\u0441 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0432 \u0441\u0440\u043e\u043a.", - "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 Somfy \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430.", - "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." - }, - "create_entry": { - "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441\u044a\u0441 Somfy." - }, - "step": { - "pick_implementation": { - "title": "\u0418\u0437\u0431\u043e\u0440 \u043d\u0430 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ca.json b/homeassistant/components/somfy/translations/ca.json deleted file mode 100644 index bc34c57c939..00000000000 --- a/homeassistant/components/somfy/translations/ca.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", - "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", - "no_url_available": "No hi ha cap URL disponible. Per a m\u00e9s informaci\u00f3 sobre aquest error, [consulta la secci\u00f3 d'ajuda]({docs_url})", - "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." - }, - "create_entry": { - "default": "Autenticaci\u00f3 exitosa" - }, - "step": { - "pick_implementation": { - "title": "Selecciona el m\u00e8tode d'autenticaci\u00f3" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/cs.json b/homeassistant/components/somfy/translations/cs.json deleted file mode 100644 index acc7d260cad..00000000000 --- a/homeassistant/components/somfy/translations/cs.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", - "missing_configuration": "Komponenta nen\u00ed nastavena. Postupujte podle dokumentace.", - "no_url_available": "Nen\u00ed k dispozici \u017e\u00e1dn\u00e1 adresa URL. Informace o t\u00e9to chyb\u011b naleznete [v sekci n\u00e1pov\u011bdy]({docs_url})", - "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." - }, - "create_entry": { - "default": "\u00dasp\u011b\u0161n\u011b ov\u011b\u0159eno" - }, - "step": { - "pick_implementation": { - "title": "Vyberte metodu ov\u011b\u0159en\u00ed" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/da.json b/homeassistant/components/somfy/translations/da.json deleted file mode 100644 index 3b7a79ef008..00000000000 --- a/homeassistant/components/somfy/translations/da.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout ved generering af autoriseret url.", - "missing_configuration": "Komponenten Somfy er ikke konfigureret. F\u00f8lg venligst dokumentationen." - }, - "create_entry": { - "default": "Godkendt med Somfy." - }, - "step": { - "pick_implementation": { - "title": "V\u00e6lg godkendelsesmetode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/de.json b/homeassistant/components/somfy/translations/de.json deleted file mode 100644 index 29a959f48ce..00000000000 --- a/homeassistant/components/somfy/translations/de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", - "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", - "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler findest du [im Hilfebereich]({docs_url}).", - "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." - }, - "create_entry": { - "default": "Erfolgreich authentifiziert" - }, - "step": { - "pick_implementation": { - "title": "W\u00e4hle die Authentifizierungsmethode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/el.json b/homeassistant/components/somfy/translations/el.json deleted file mode 100644 index 8d1f457ae10..00000000000 --- a/homeassistant/components/somfy/translations/el.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7\u03c2.", - "missing_configuration": "\u03a4\u03bf \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03bf \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7.", - "no_url_available": "\u0394\u03b5\u03bd \u03c5\u03c0\u03ac\u03c1\u03c7\u03b5\u03b9 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL. \u0393\u03b9\u03b1 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2 \u03c3\u03c7\u03b5\u03c4\u03b9\u03ba\u03ac \u03bc\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1, [\u03b5\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03bd\u03cc\u03c4\u03b7\u03c4\u03b1 \u03b2\u03bf\u03ae\u03b8\u03b5\u03b9\u03b1\u03c2] ( {docs_url} )", - "single_instance_allowed": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b7\u03ba\u03b5 \u03ae\u03b4\u03b7. \u039c\u03cc\u03bd\u03bf \u03bc\u03af\u03b1 \u03c0\u03b1\u03c1\u03b1\u03bc\u03b5\u03c4\u03c1\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae." - }, - "create_entry": { - "default": "\u0395\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2" - }, - "step": { - "pick_implementation": { - "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae \u03bc\u03b5\u03b8\u03cc\u03b4\u03bf\u03c5 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/en.json b/homeassistant/components/somfy/translations/en.json deleted file mode 100644 index e0072d1da4d..00000000000 --- a/homeassistant/components/somfy/translations/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout generating authorize URL.", - "missing_configuration": "The component is not configured. Please follow the documentation.", - "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "single_instance_allowed": "Already configured. Only a single configuration possible." - }, - "create_entry": { - "default": "Successfully authenticated" - }, - "step": { - "pick_implementation": { - "title": "Pick Authentication Method" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/en_GB.json b/homeassistant/components/somfy/translations/en_GB.json deleted file mode 100644 index ddf7ee6d5dd..00000000000 --- a/homeassistant/components/somfy/translations/en_GB.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout generating authorise URL." - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/es-419.json b/homeassistant/components/somfy/translations/es-419.json deleted file mode 100644 index 6acd9bb6bb8..00000000000 --- a/homeassistant/components/somfy/translations/es-419.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tiempo de espera agotado para generar la URL de autorizaci\u00f3n.", - "missing_configuration": "El componente Somfy no est\u00e1 configurado. Por favor, siga la documentaci\u00f3n." - }, - "create_entry": { - "default": "Autenticado con \u00e9xito con Somfy." - }, - "step": { - "pick_implementation": { - "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/es.json b/homeassistant/components/somfy/translations/es.json deleted file mode 100644 index 2f8b35f5af8..00000000000 --- a/homeassistant/components/somfy/translations/es.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tiempo de espera agotado durante la generaci\u00f3n de la URL de autorizaci\u00f3n.", - "missing_configuration": "El componente no est\u00e1 configurado. Consulta la documentaci\u00f3n.", - "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [consulta la secci\u00f3n de ayuda]({docs_url})", - "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." - }, - "create_entry": { - "default": "Autenticado correctamente" - }, - "step": { - "pick_implementation": { - "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/et.json b/homeassistant/components/somfy/translations/et.json deleted file mode 100644 index 9239f7df0ef..00000000000 --- a/homeassistant/components/somfy/translations/et.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Kinnitus-URLi loomise ajal\u00f5pp.", - "missing_configuration": "Komponent pole seadistatud. Palun loe dokumentatsiooni.", - "no_url_available": "URL pole saadaval. Rohkem teavet [check the help section]({docs_url})", - "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." - }, - "create_entry": { - "default": "Edukalt tuvastatud" - }, - "step": { - "pick_implementation": { - "title": "Vali tuvastusmeetod" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/fr.json b/homeassistant/components/somfy/translations/fr.json deleted file mode 100644 index 0c7a25831bc..00000000000 --- a/homeassistant/components/somfy/translations/fr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification expir\u00e9.", - "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", - "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide]({docs_url})", - "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." - }, - "create_entry": { - "default": "Authentification r\u00e9ussie" - }, - "step": { - "pick_implementation": { - "title": "S\u00e9lectionner une m\u00e9thode d'authentification" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/he.json b/homeassistant/components/somfy/translations/he.json deleted file mode 100644 index c68d7f74d85..00000000000 --- a/homeassistant/components/somfy/translations/he.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", - "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", - "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", - "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." - }, - "create_entry": { - "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" - }, - "step": { - "pick_implementation": { - "title": "\u05d1\u05d7\u05e8 \u05e9\u05d9\u05d8\u05ea \u05d0\u05d9\u05de\u05d5\u05ea" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/hr.json b/homeassistant/components/somfy/translations/hr.json deleted file mode 100644 index a601eb2b9bf..00000000000 --- a/homeassistant/components/somfy/translations/hr.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "create_entry": { - "default": "Uspje\u0161no autentificirano sa Somfy." - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/hu.json b/homeassistant/components/somfy/translations/hu.json deleted file mode 100644 index 96b873b2c42..00000000000 --- a/homeassistant/components/somfy/translations/hu.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", - "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3 [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lhat\u00f3.", - "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." - }, - "create_entry": { - "default": "Sikeres hiteles\u00edt\u00e9s" - }, - "step": { - "pick_implementation": { - "title": "V\u00e1lasszon egy hiteles\u00edt\u00e9si m\u00f3dszert" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/id.json b/homeassistant/components/somfy/translations/id.json deleted file mode 100644 index 2d229de00d5..00000000000 --- a/homeassistant/components/somfy/translations/id.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", - "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", - "no_url_available": "Tidak ada URL yang tersedia. Untuk informasi tentang kesalahan ini, [lihat bagian bantuan]({docs_url})", - "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." - }, - "create_entry": { - "default": "Berhasil diautentikasi" - }, - "step": { - "pick_implementation": { - "title": "Pilih Metode Autentikasi" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/it.json b/homeassistant/components/somfy/translations/it.json deleted file mode 100644 index 0201e1e2569..00000000000 --- a/homeassistant/components/somfy/translations/it.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.", - "missing_configuration": "Il componente non \u00e8 configurato. Segui la documentazione.", - "no_url_available": "Nessun URL disponibile. Per informazioni su questo errore, [controlla la sezione della guida]({docs_url})", - "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." - }, - "create_entry": { - "default": "Autenticazione riuscita" - }, - "step": { - "pick_implementation": { - "title": "Scegli il metodo di autenticazione" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ja.json b/homeassistant/components/somfy/translations/ja.json deleted file mode 100644 index 3c25bd7bb8f..00000000000 --- a/homeassistant/components/somfy/translations/ja.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", - "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", - "no_url_available": "\u4f7f\u7528\u53ef\u80fd\u306aURL\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001[\u30d8\u30eb\u30d7\u30bb\u30af\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044]({docs_url})", - "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" - }, - "create_entry": { - "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" - }, - "step": { - "pick_implementation": { - "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ko.json b/homeassistant/components/somfy/translations/ko.json deleted file mode 100644 index 568c8d05116..00000000000 --- a/homeassistant/components/somfy/translations/ko.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", - "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." - }, - "create_entry": { - "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" - }, - "step": { - "pick_implementation": { - "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/lb.json b/homeassistant/components/somfy/translations/lb.json deleted file mode 100644 index a463473c2e1..00000000000 --- a/homeassistant/components/somfy/translations/lb.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", - "missing_configuration": "Komponent ass nach net konfigur\u00e9iert. Folleg w.e.g der Dokumentatioun.", - "no_url_available": "Keng URL disponibel. Fir Informatiounen iwwert d\u00ebse Feeler, [kuck H\u00ebllef Sektioun]({docs_url})", - "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun ass m\u00e9iglech." - }, - "create_entry": { - "default": "Erfollegr\u00e4ich authentifiz\u00e9iert." - }, - "step": { - "pick_implementation": { - "title": "Wiel Authentifikatiouns Method aus" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/nl.json b/homeassistant/components/somfy/translations/nl.json deleted file mode 100644 index efd07952467..00000000000 --- a/homeassistant/components/somfy/translations/nl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Time-out bij het genereren van autorisatie-URL.", - "missing_configuration": "Integratie niet geconfigureerd. Raadpleeg de documentatie.", - "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout, [raadpleeg de documentatie]({docs_url})", - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." - }, - "create_entry": { - "default": "Authenticatie geslaagd" - }, - "step": { - "pick_implementation": { - "title": "Kies een authenticatie methode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/no.json b/homeassistant/components/somfy/translations/no.json deleted file mode 100644 index 57bc6e68436..00000000000 --- a/homeassistant/components/somfy/translations/no.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", - "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", - "no_url_available": "Ingen URL tilgjengelig. For informasjon om denne feilen, [sjekk hjelpseksjonen]({docs_url})", - "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." - }, - "create_entry": { - "default": "Vellykket godkjenning" - }, - "step": { - "pick_implementation": { - "title": "Velg godkjenningsmetode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/pl.json b/homeassistant/components/somfy/translations/pl.json deleted file mode 100644 index baeb38e755e..00000000000 --- a/homeassistant/components/somfy/translations/pl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji", - "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", - "no_url_available": "Brak dost\u0119pnego adresu URL. Aby uzyska\u0107 informacje na temat tego b\u0142\u0119du, [sprawd\u017a sekcj\u0119 pomocy] ({docs_url})", - "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." - }, - "create_entry": { - "default": "Pomy\u015blnie uwierzytelniono" - }, - "step": { - "pick_implementation": { - "title": "Wybierz metod\u0119 uwierzytelniania" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/pt-BR.json b/homeassistant/components/somfy/translations/pt-BR.json deleted file mode 100644 index 8ad5fac9044..00000000000 --- a/homeassistant/components/somfy/translations/pt-BR.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", - "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "N\u00e3o h\u00e1 URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", - "single_instance_allowed": "J\u00e1 configurado. Apenas uma configura\u00e7\u00e3o \u00e9 poss\u00edvel." - }, - "create_entry": { - "default": "Autenticado com sucesso" - }, - "step": { - "pick_implementation": { - "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/pt.json b/homeassistant/components/somfy/translations/pt.json deleted file mode 100644 index 592ccd85589..00000000000 --- a/homeassistant/components/somfy/translations/pt.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tempo excedido a gerar um URL de autoriza\u00e7\u00e3o", - "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "Nenhum URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a sec\u00e7\u00e3o de ajuda]({docs_url})", - "single_instance_allowed": "J\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel." - }, - "create_entry": { - "default": "Autenticado com sucesso" - }, - "step": { - "pick_implementation": { - "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ru.json b/homeassistant/components/somfy/translations/ru.json deleted file mode 100644 index 38ac0dda412..00000000000 --- a/homeassistant/components/somfy/translations/ru.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", - "missing_configuration": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439.", - "no_url_available": "URL-\u0430\u0434\u0440\u0435\u0441 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d. \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e\u0431 \u044d\u0442\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435.", - "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." - }, - "create_entry": { - "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." - }, - "step": { - "pick_implementation": { - "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043f\u043e\u0441\u043e\u0431 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/sk.json b/homeassistant/components/somfy/translations/sk.json deleted file mode 100644 index c19b1a0b70c..00000000000 --- a/homeassistant/components/somfy/translations/sk.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "create_entry": { - "default": "\u00daspe\u0161ne overen\u00e9" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/sl.json b/homeassistant/components/somfy/translations/sl.json deleted file mode 100644 index 3b9bc038fe6..00000000000 --- a/homeassistant/components/somfy/translations/sl.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", - "missing_configuration": "Komponenta Somfy ni konfigurirana. Upo\u0161tevajte dokumentacijo." - }, - "create_entry": { - "default": "Uspe\u0161no overjen s Somfy-jem." - }, - "step": { - "pick_implementation": { - "title": "Izberite na\u010din preverjanja pristnosti" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/sv.json b/homeassistant/components/somfy/translations/sv.json deleted file mode 100644 index cf0a8ec75bf..00000000000 --- a/homeassistant/components/somfy/translations/sv.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout vid skapandet av en auktoriseringsadress.", - "missing_configuration": "Somfy-komponenten \u00e4r inte konfigurerad. V\u00e4nligen f\u00f6lj dokumentationen." - }, - "create_entry": { - "default": "Lyckad autentisering med Somfy." - }, - "step": { - "pick_implementation": { - "title": "V\u00e4lj autentiseringsmetod" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/tr.json b/homeassistant/components/somfy/translations/tr.json deleted file mode 100644 index b3b645cd52d..00000000000 --- a/homeassistant/components/somfy/translations/tr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", - "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", - "no_url_available": "Kullan\u0131labilir URL yok. Bu hata hakk\u0131nda bilgi i\u00e7in [yard\u0131m b\u00f6l\u00fcm\u00fcne bak\u0131n]({docs_url})", - "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." - }, - "create_entry": { - "default": "Ba\u015far\u0131yla do\u011fruland\u0131" - }, - "step": { - "pick_implementation": { - "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/uk.json b/homeassistant/components/somfy/translations/uk.json deleted file mode 100644 index 207169ad6b0..00000000000 --- a/homeassistant/components/somfy/translations/uk.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u041c\u0438\u043d\u0443\u0432 \u0447\u0430\u0441 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0456\u0457 \u043f\u043e\u0441\u0438\u043b\u0430\u043d\u043d\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457.", - "missing_configuration": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0438 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f. \u0411\u0443\u0434\u044c \u043b\u0430\u0441\u043a\u0430, \u043e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 \u0456\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0456\u044f\u043c\u0438.", - "no_url_available": "URL-\u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430. \u041e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0456\u0454\u044e] ({docs_url}) \u0434\u043b\u044f \u043e\u0442\u0440\u0438\u043c\u0430\u043d\u043d\u044f \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457 \u043f\u0440\u043e \u0446\u044e \u043f\u043e\u043c\u0438\u043b\u043a\u0443.", - "single_instance_allowed": "\u0412\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e. \u041c\u043e\u0436\u043b\u0438\u0432\u0430 \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044f." - }, - "create_entry": { - "default": "\u0410\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044e \u0443\u0441\u043f\u0456\u0448\u043d\u043e \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e." - }, - "step": { - "pick_implementation": { - "title": "\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u0441\u043f\u043e\u0441\u0456\u0431 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u0457" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/zh-Hant.json b/homeassistant/components/somfy/translations/zh-Hant.json deleted file mode 100644 index 8dccd6771cb..00000000000 --- a/homeassistant/components/somfy/translations/zh-Hant.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002", - "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", - "no_url_available": "\u6c92\u6709\u53ef\u7528\u7684\u7db2\u5740\u3002\u95dc\u65bc\u6b64\u932f\u8aa4\u66f4\u8a73\u7d30\u8a0a\u606f\uff0c[\u9ede\u9078\u5354\u52a9\u7ae0\u7bc0]({docs_url})", - "single_instance_allowed": "\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3001\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" - }, - "create_entry": { - "default": "\u5df2\u6210\u529f\u8a8d\u8b49" - }, - "step": { - "pick_implementation": { - "title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 768d12da45b..de38ac271ce 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Somfy MyLink integration.""" +from __future__ import annotations + import asyncio from copy import deepcopy import logging @@ -110,7 +112,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index d1b09175deb..43c9ca63bb5 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -1,5 +1,6 @@ """Cover Platform for the Somfy MyLink component.""" import logging +from typing import Any from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -87,7 +88,7 @@ class SomfyShade(RestoreEntity, CoverEntity): name=name, ) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._attr_is_closing = True self.async_write_ha_state() @@ -102,7 +103,7 @@ class SomfyShade(RestoreEntity, CoverEntity): self._attr_is_closing = None self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._attr_is_opening = True self.async_write_ha_state() @@ -117,11 +118,11 @@ class SomfyShade(RestoreEntity, CoverEntity): self._attr_is_opening = None self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.somfy_mylink.move_stop(self._target_id) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Complete the initialization.""" await super().async_added_to_hass() # Restore the last state diff --git a/homeassistant/components/somfy_mylink/translations/bg.json b/homeassistant/components/somfy_mylink/translations/bg.json index 4983c9a14b2..ca0ed419f99 100644 --- a/homeassistant/components/somfy_mylink/translations/bg.json +++ b/homeassistant/components/somfy_mylink/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index 0bdcca6c033..3ea386faa78 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Sonarr.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -10,7 +11,7 @@ from aiopyarr.sonarr_client import SonarrClient import voluptuous as vol import yarl -from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult @@ -28,7 +29,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict) -> None: +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -52,27 +53,28 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize the flow.""" - self.entry = None + self.entry: ConfigEntry | None = None @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> SonarrOptionsFlowHandler: """Get the options flow for this handler.""" return SonarrOptionsFlowHandler(config_entry) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.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 + self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm reauth dialog.""" if user_input is None: + assert self.entry is not None return self.async_show_form( step_id="reauth_confirm", description_placeholders={"url": self.entry.data[CONF_URL]}, @@ -95,7 +97,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL try: - await validate_input(self.hass, user_input) + await _validate_input(self.hass, user_input) except ArrAuthenticationException: errors = {"base": "invalid_auth"} except ArrException: @@ -120,19 +122,20 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_reauth_update_entry(self, data: dict) -> FlowResult: + async def _async_reauth_update_entry(self, data: dict[str, Any]) -> FlowResult: """Update existing config entry.""" + assert self.entry is not None self.hass.config_entries.async_update_entry(self.entry, data=data) await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") - def _get_user_data_schema(self) -> dict[str, Any]: + def _get_user_data_schema(self) -> dict[vol.Marker, type]: """Get the data schema to display user form.""" if self.entry: return {vol.Required(CONF_API_KEY): str} - data_schema: dict[str, Any] = { + data_schema: dict[vol.Marker, type] = { vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, } @@ -148,11 +151,13 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): class SonarrOptionsFlowHandler(OptionsFlow): """Handle Sonarr client options.""" - def __init__(self, config_entry): + 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, int] | None = None): + async def async_step_init( + self, user_input: dict[str, int] | None = None + ) -> FlowResult: """Manage Sonarr options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index 6a9b00d2041..6b34b077888 100644 --- a/homeassistant/components/sonarr/manifest.json +++ b/homeassistant/components/sonarr/manifest.json @@ -3,7 +3,7 @@ "name": "Sonarr", "documentation": "https://www.home-assistant.io/integrations/sonarr", "codeowners": ["@ctalkington"], - "requirements": ["aiopyarr==22.2.2"], + "requirements": ["aiopyarr==22.6.0"], "config_flow": true, "quality_scale": "silver", "iot_class": "local_polling", diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 1d7acd8d8dc..e3f65d754dd 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections import OrderedDict +from dataclasses import dataclass, field import datetime from functools import partial import logging @@ -21,7 +22,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import async_track_time_interval, call_later from homeassistant.helpers.typing import ConfigType @@ -74,6 +75,14 @@ CONFIG_SCHEMA = vol.Schema( ) +@dataclass +class UnjoinData: + """Class to track data necessary for unjoin coalescing.""" + + speakers: list[SonosSpeaker] + event: asyncio.Event = field(default_factory=asyncio.Event) + + class SonosData: """Storage class for platform global data.""" @@ -89,6 +98,7 @@ class SonosData: self.boot_counts: dict[str, int] = {} self.mdns_names: dict[str, str] = {} self.entity_id_mappings: dict[str, SonosSpeaker] = {} + self.unjoin_data: dict[str, UnjoinData] = {} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -190,6 +200,14 @@ class SonosDiscoveryManager: for speaker in self.data.discovered.values(): speaker.activity_stats.log_report() speaker.event_stats.log_report() + if zgs := next( + speaker.soco.zone_group_state for speaker in self.data.discovered.values() + ): + _LOGGER.debug( + "ZoneGroupState stats: (%s/%s) processed", + zgs.processed_count, + zgs.total_requests, + ) await asyncio.gather( *(speaker.async_offline() for speaker in self.data.discovered.values()) ) @@ -396,3 +414,17 @@ class SonosDiscoveryManager: AVAILABILITY_CHECK_INTERVAL, ) ) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove Sonos config entry from a device.""" + known_devices = hass.data[DATA_SONOS].discovered.keys() + for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN: + continue + uid = identifier[1] + if uid not in known_devices: + return True + return False diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 077ca3a68cd..463884e1ea8 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -136,4 +136,8 @@ async def async_generate_speaker_info( payload["media"] = await async_generate_media_info(hass, speaker) payload["activity_stats"] = speaker.activity_stats.report() payload["event_stats"] = speaker.event_stats.report() + payload["zone_group_state_stats"] = { + "processed": speaker.soco.zone_group_state.processed_count, + "total_requests": speaker.soco.zone_group_state.total_requests, + } return payload diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py index dd2d30796cc..7ff5dacd293 100644 --- a/homeassistant/components/sonos/exception.py +++ b/homeassistant/components/sonos/exception.py @@ -7,6 +7,10 @@ class UnknownMediaType(BrowseError): """Unknown media type.""" +class SonosSubscriptionsFailed(HomeAssistantError): + """Subscription creation failed.""" + + class SonosUpdateError(HomeAssistantError): """Update failed.""" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 9144ca559f2..b8506cd2783 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["soco==0.27.1"], + "requirements": ["soco==0.28.0"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], "zeroconf": ["_sonos._tcp.local."], diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 938a651c34d..c68110d9763 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -44,8 +44,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later -from . import media_browser +from . import UnjoinData, media_browser from .const import ( DATA_SONOS, DOMAIN as SONOS_DOMAIN, @@ -67,6 +68,7 @@ from .speaker import SonosMedia, SonosSpeaker _LOGGER = logging.getLogger(__name__) +LONG_SERVICE_TIMEOUT = 30.0 VOLUME_INCREMENT = 2 REPEAT_TO_SONOS = { @@ -580,36 +582,44 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if result.shuffle: self.set_shuffle(True) if enqueue == MediaPlayerEnqueue.ADD: - plex_plugin.add_to_queue(result.media) + plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT) elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 - new_pos = plex_plugin.add_to_queue(result.media, position=pos) + new_pos = plex_plugin.add_to_queue( + result.media, position=pos, timeout=LONG_SERVICE_TIMEOUT + ) if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() - plex_plugin.add_to_queue(result.media) + plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT) soco.play_from_queue(0) return share_link = self.coordinator.share_link if share_link.is_share_link(media_id): if enqueue == MediaPlayerEnqueue.ADD: - share_link.add_share_link_to_queue(media_id) + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT + ) elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 - new_pos = share_link.add_share_link_to_queue(media_id, position=pos) + new_pos = share_link.add_share_link_to_queue( + media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT + ) if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() - share_link.add_share_link_to_queue(media_id) + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT + ) soco.play_from_queue(0) elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): # If media ID is a relative URL, we serve it from HA. @@ -753,21 +763,37 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_join_players(self, group_members): """Join `group_members` as a player group with the current player.""" - async with self.hass.data[DATA_SONOS].topology_condition: - speakers = [] - for entity_id in group_members: - if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get( - entity_id - ): - speakers.append(speaker) - else: - raise HomeAssistantError( - f"Not a known Sonos entity_id: {entity_id}" - ) + speakers = [] + for entity_id in group_members: + if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get(entity_id): + speakers.append(speaker) + else: + raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") - await self.hass.async_add_executor_job(self.speaker.join, speakers) + await SonosSpeaker.join_multi(self.hass, self.speaker, speakers) async def async_unjoin_player(self): - """Remove this player from any group.""" - async with self.hass.data[DATA_SONOS].topology_condition: - await self.hass.async_add_executor_job(self.speaker.unjoin) + """Remove this player from any group. + + Coalesces all calls within 0.5s to allow use of SonosSpeaker.unjoin_multi() + which optimizes the order in which speakers are removed from their groups. + Removing coordinators last better preserves playqueues on the speakers. + """ + sonos_data = self.hass.data[DATA_SONOS] + household_id = self.speaker.household_id + + async def async_process_unjoin(now: datetime.datetime) -> None: + """Process the unjoin with all remove requests within the coalescing period.""" + unjoin_data = sonos_data.unjoin_data.pop(household_id) + await SonosSpeaker.unjoin_multi(self.hass, unjoin_data.speakers) + unjoin_data.event.set() + + if unjoin_data := sonos_data.unjoin_data.get(household_id): + unjoin_data.speakers.append(self.speaker) + else: + unjoin_data = sonos_data.unjoin_data[household_id] = UnjoinData( + speakers=[self.speaker] + ) + async_call_later(self.hass, 0.5, async_process_unjoin) + + await unjoin_data.event.wait() diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index b0a10690ce6..3b034423471 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -20,6 +20,8 @@ LEVEL_TYPES = { "bass": (-10, 10), "treble": (-10, 10), "sub_gain": (-15, 15), + "surround_level": (-15, 15), + "music_surround_level": (-15, 15), } _LOGGER = logging.getLogger(__name__) @@ -73,7 +75,7 @@ class SonosLevelEntity(SonosEntity, NumberEntity): name_suffix = level_type.replace("_", " ").title() self._attr_name = f"{self.speaker.zone_name} {name_suffix}" self.level_type = level_type - self._attr_min_value, self._attr_max_value = valid_range + self._attr_native_min_value, self._attr_native_max_value = valid_range async def _async_fallback_poll(self) -> None: """Poll the value if subscriptions are not working.""" @@ -86,11 +88,11 @@ class SonosLevelEntity(SonosEntity, NumberEntity): setattr(self.speaker, self.level_type, state) @soco_error() - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Set a new value.""" setattr(self.soco, self.level_type, value) @property - def value(self) -> float: + def native_value(self) -> float: """Return the current value.""" return getattr(self.speaker, self.level_type) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 5d4199ec905..0c5bec06dfb 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -57,7 +57,7 @@ from .const import ( SONOS_VANISHED, SUBSCRIPTION_TIMEOUT, ) -from .exception import S1BatteryMissing, SonosUpdateError +from .exception import S1BatteryMissing, SonosSubscriptionsFailed, SonosUpdateError from .favorites import SonosFavorites from .helpers import soco_error from .media import SonosMedia @@ -77,7 +77,7 @@ SUBSCRIPTION_SERVICES = [ "renderingControl", "zoneGroupTopology", ] -SUPPORTED_VANISH_REASONS = ("sleeping", "upgrade") +SUPPORTED_VANISH_REASONS = ("sleeping", "switch to bluetooth", "upgrade") UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"] @@ -137,6 +137,7 @@ class SonosSpeaker: self.cross_fade: bool | None = None self.bass: int | None = None self.treble: int | None = None + self.loudness: bool | None = None # Home theater self.audio_delay: int | None = None @@ -145,6 +146,9 @@ class SonosSpeaker: self.sub_enabled: bool | None = None self.sub_gain: int | None = None self.surround_enabled: bool | None = None + self.surround_mode: bool | None = None + self.surround_level: int | None = None + self.music_surround_level: int | None = None # Misc features self.buttons_enabled: bool | None = None @@ -320,12 +324,29 @@ class SonosSpeaker: async with self._subscription_lock: if self._subscriptions: return - await self._async_subscribe() + try: + await self._async_subscribe() + except SonosSubscriptionsFailed: + _LOGGER.warning("Creating subscriptions failed for %s", self.zone_name) + await self._async_offline() async def _async_subscribe(self) -> None: """Create event subscriptions.""" _LOGGER.debug("Creating subscriptions for %s", self.zone_name) + subscriptions = [ + self._subscribe(getattr(self.soco, service), self.async_dispatch_event) + for service in SUBSCRIPTION_SERVICES + ] + results = await asyncio.gather(*subscriptions, return_exceptions=True) + for result in results: + self.log_subscription_result( + result, "Creating subscription", logging.WARNING + ) + + if any(isinstance(result, Exception) for result in results): + raise SonosSubscriptionsFailed + # Create a polling task in case subscriptions fail or callback events do not arrive if not self._poll_timer: self._poll_timer = async_track_time_interval( @@ -338,16 +359,6 @@ class SonosSpeaker: SCAN_INTERVAL, ) - subscriptions = [ - self._subscribe(getattr(self.soco, service), self.async_dispatch_event) - for service in SUBSCRIPTION_SERVICES - ] - results = await asyncio.gather(*subscriptions, return_exceptions=True) - for result in results: - self.log_subscription_result( - result, "Creating subscription", logging.WARNING - ) - async def _subscribe( self, target: SubscriptionBase, sub_callback: Callable ) -> None: @@ -506,16 +517,27 @@ class SonosSpeaker: if "mute" in variables: self.muted = variables["mute"]["Master"] == "1" + if loudness := variables.get("loudness"): + self.loudness = loudness["Master"] == "1" + for bool_var in ( "dialog_level", "night_mode", "sub_enabled", "surround_enabled", + "surround_mode", ): if bool_var in variables: setattr(self, bool_var, variables[bool_var] == "1") - for int_var in ("audio_delay", "bass", "treble", "sub_gain"): + for int_var in ( + "audio_delay", + "bass", + "treble", + "sub_gain", + "surround_level", + "music_surround_level", + ): if int_var in variables: setattr(self, int_var, variables[int_var]) @@ -570,6 +592,11 @@ class SonosSpeaker: await self.async_offline() async def async_offline(self) -> None: + """Handle removal of speaker when unavailable.""" + async with self._subscription_lock: + await self._async_offline() + + async def _async_offline(self) -> None: """Handle removal of speaker when unavailable.""" if not self.available: return @@ -587,8 +614,7 @@ class SonosSpeaker: self._poll_timer() self._poll_timer = None - async with self._subscription_lock: - await self.async_unsubscribe() + await self.async_unsubscribe() self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid) @@ -857,9 +883,9 @@ class SonosSpeaker: for speaker in speakers: if speaker.soco.uid != self.soco.uid: - speaker.soco.join(self.soco) - speaker.coordinator = self if speaker not in group: + speaker.soco.join(self.soco) + speaker.coordinator = self group.append(speaker) return group @@ -880,6 +906,8 @@ class SonosSpeaker: @soco_error() def unjoin(self) -> None: """Unjoin the player from a group.""" + if self.sonos_group == [self]: + return self.soco.unjoin() self.coordinator = None @@ -1058,8 +1086,8 @@ class SonosSpeaker: except asyncio.TimeoutError: _LOGGER.warning("Timeout waiting for target groups %s", groups) - for speaker in hass.data[DATA_SONOS].discovered.values(): - speaker.soco._zgs_cache.clear() # pylint: disable=protected-access + any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values())) + any_speaker.soco.zone_group_state.clear_cache() # # Media and playback state handlers diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index b5fea08e418..53911d85d3e 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -38,6 +38,8 @@ ATTR_VOLUME = "volume" ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" ATTR_CROSSFADE = "cross_fade" +ATTR_LOUDNESS = "loudness" +ATTR_MUSIC_PLAYBACK_FULL_VOLUME = "surround_mode" ATTR_NIGHT_SOUND = "night_mode" ATTR_SPEECH_ENHANCEMENT = "dialog_level" ATTR_STATUS_LIGHT = "status_light" @@ -48,6 +50,8 @@ ATTR_TOUCH_CONTROLS = "buttons_enabled" ALL_FEATURES = ( ATTR_TOUCH_CONTROLS, ATTR_CROSSFADE, + ATTR_LOUDNESS, + ATTR_MUSIC_PLAYBACK_FULL_VOLUME, ATTR_NIGHT_SOUND, ATTR_SPEECH_ENHANCEMENT, ATTR_SUB_ENABLED, @@ -64,6 +68,8 @@ POLL_REQUIRED = ( FRIENDLY_NAMES = { ATTR_CROSSFADE: "Crossfade", + ATTR_LOUDNESS: "Loudness", + ATTR_MUSIC_PLAYBACK_FULL_VOLUME: "Surround Music Full Volume", ATTR_NIGHT_SOUND: "Night Sound", ATTR_SPEECH_ENHANCEMENT: "Speech Enhancement", ATTR_STATUS_LIGHT: "Status Light", @@ -73,6 +79,8 @@ FRIENDLY_NAMES = { } FEATURE_ICONS = { + ATTR_LOUDNESS: "mdi:bullhorn-variant", + ATTR_MUSIC_PLAYBACK_FULL_VOLUME: "mdi:music-note-plus", ATTR_NIGHT_SOUND: "mdi:chat-sleep", ATTR_SPEECH_ENHANCEMENT: "mdi:ear-hearing", ATTR_CROSSFADE: "mdi:swap-horizontal", diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 7c9ade3bee1..f8a5191d9db 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -523,7 +523,7 @@ class SoundTouchDevice(MediaPlayerEntity): for slave in zone_slaves: slave_instance = self._get_instance_by_ip(slave.device_ip) - if slave_instance: + if slave_instance and slave_instance.entity_id != master: slaves.append(slave_instance.entity_id) attributes = { diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index d519e6b7f2b..b78703666bc 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -1,6 +1,8 @@ """Support for Vanderbilt (formerly Siemens) SPC alarm systems.""" from __future__ import annotations +from pyspcwebgw import SpcWebGateway +from pyspcwebgw.area import Area from pyspcwebgw.const import AreaMode import homeassistant.components.alarm_control_panel as alarm @@ -20,7 +22,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_API, SIGNAL_UPDATE_ALARM -def _get_alarm_state(area): +def _get_alarm_state(area: Area) -> str | None: """Get the alarm state.""" if area.verified_alarm: @@ -44,25 +46,26 @@ async def async_setup_platform( """Set up the SPC alarm control panel platform.""" if discovery_info is None: return - api = hass.data[DATA_API] + api: SpcWebGateway = hass.data[DATA_API] async_add_entities([SpcAlarm(area=area, api=api) for area in api.areas.values()]) class SpcAlarm(alarm.AlarmControlPanelEntity): """Representation of the SPC alarm panel.""" + _attr_should_poll = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) - def __init__(self, area, api): + def __init__(self, area: Area, api: SpcWebGateway) -> None: """Initialize the SPC alarm panel.""" self._area = area self._api = api - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call for adding new entities.""" self.async_on_remove( async_dispatcher_connect( @@ -73,46 +76,41 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._area.name @property - def changed_by(self): + def changed_by(self) -> str: """Return the user the last change was triggered by.""" return self._area.last_changed_by @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return _get_alarm_state(self._area) - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.UNSET) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_A) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm home command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_B) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.FULL_SET) diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index 87068d97b8a..c4aaefdd518 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -1,7 +1,9 @@ """Support for Vanderbilt (formerly Siemens) SPC alarm systems.""" from __future__ import annotations +from pyspcwebgw import SpcWebGateway from pyspcwebgw.const import ZoneInput, ZoneType +from pyspcwebgw.zone import Zone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,12 +17,12 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_API, SIGNAL_UPDATE_SENSOR -def _get_device_class(zone_type): +def _get_device_class(zone_type: ZoneType) -> BinarySensorDeviceClass | None: return { ZoneType.ALARM: BinarySensorDeviceClass.MOTION, ZoneType.ENTRY_EXIT: BinarySensorDeviceClass.OPENING, ZoneType.FIRE: BinarySensorDeviceClass.SMOKE, - ZoneType.TECHNICAL: "power", + ZoneType.TECHNICAL: BinarySensorDeviceClass.POWER, }.get(zone_type) @@ -33,7 +35,7 @@ async def async_setup_platform( """Set up the SPC binary sensor.""" if discovery_info is None: return - api = hass.data[DATA_API] + api: SpcWebGateway = hass.data[DATA_API] async_add_entities( [ SpcBinarySensor(zone) @@ -46,11 +48,13 @@ async def async_setup_platform( class SpcBinarySensor(BinarySensorEntity): """Representation of a sensor based on a SPC zone.""" - def __init__(self, zone): + _attr_should_poll = False + + def __init__(self, zone: Zone) -> None: """Initialize the sensor device.""" self._zone = zone - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call for adding new entities.""" self.async_on_remove( async_dispatcher_connect( @@ -61,26 +65,21 @@ class SpcBinarySensor(BinarySensorEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._zone.name @property - def is_on(self): + def is_on(self) -> bool: """Whether the device is switched on.""" return self._zone.input == ZoneInput.OPEN @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass | None: """Return the device class.""" return _get_device_class(self._zone.type) diff --git a/homeassistant/components/spider/translations/sv.json b/homeassistant/components/spider/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/spider/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index e1780ff9d40..013063308bb 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Spotify.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -56,7 +57,7 @@ class SpotifyFlowHandler( return self.async_create_entry(title=name, data=data) - async def async_step_reauth(self, entry: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] @@ -64,7 +65,8 @@ class SpotifyFlowHandler( persistent_notification.async_create( self.hass, - f"Spotify integration for account {entry['id']} needs to be re-authenticated. Please go to the integrations page to re-configure it.", + f"Spotify integration for account {entry_data['id']} needs to be " + "re-authenticated. Please go to the integrations page to re-configure it.", "Spotify re-authentication", "spotify_reauth", ) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 979f262e54a..2940700d230 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -2,7 +2,7 @@ "domain": "spotify", "name": "Spotify", "documentation": "https://www.home-assistant.io/integrations/spotify", - "requirements": ["spotipy==2.19.0"], + "requirements": ["spotipy==2.20.0"], "zeroconf": ["_spotify-connect._tcp.local."], "dependencies": ["application_credentials"], "codeowners": ["@frenck"], diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 4562b945008..0e6c68071cc 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -2,7 +2,7 @@ "domain": "sql", "name": "SQL", "documentation": "https://www.home-assistant.io/integrations/sql", - "requirements": ["sqlalchemy==1.4.37"], + "requirements": ["sqlalchemy==1.4.38"], "codeowners": ["@dgomes", "@gjohansson-ST"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/components/sql/translations/es.json b/homeassistant/components/sql/translations/es.json index 7117115efc7..6811fc498f9 100644 --- a/homeassistant/components/sql/translations/es.json +++ b/homeassistant/components/sql/translations/es.json @@ -4,11 +4,14 @@ "already_configured": "La cuenta ya est\u00e1 configurada" }, "error": { - "db_url_invalid": "URL de la base de datos inv\u00e1lido" + "db_url_invalid": "URL de la base de datos inv\u00e1lido", + "query_invalid": "Consulta SQL no v\u00e1lida" }, "step": { "user": { "data": { + "column": "Columna", + "db_url": "URL de la base de datos", "name": "Nombre", "query": "Selecciona la consulta", "unit_of_measurement": "Unidad de medida", diff --git a/homeassistant/components/squeezebox/translations/sv.json b/homeassistant/components/squeezebox/translations/sv.json new file mode 100644 index 00000000000..796ddb0da2e --- /dev/null +++ b/homeassistant/components/squeezebox/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "edit": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/srp_energy/translations/sv.json b/homeassistant/components/srp_energy/translations/sv.json new file mode 100644 index 00000000000..880970c74ff --- /dev/null +++ b/homeassistant/components/srp_energy/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan inte ansluta", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index f0db05d9015..88e3d0f4286 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.31.1"], + "requirements": ["async-upnp-client==0.31.2"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 20e389e48b5..4fb8457a779 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -1,4 +1,8 @@ """Support for StarLine lock.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -31,12 +35,12 @@ class StarlineLock(StarlineEntity, LockEntity): super().__init__(account, device, "lock", "Security") @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return super().available and self._device.online @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool]: """Return the state attributes of the lock. Possible dictionary keys: @@ -57,21 +61,21 @@ class StarlineLock(StarlineEntity, LockEntity): return self._device.alarm_state @property - def icon(self): + def icon(self) -> str: """Icon to use in the frontend, if any.""" return ( "mdi:shield-check-outline" if self.is_locked else "mdi:shield-alert-outline" ) @property - def is_locked(self): + def is_locked(self) -> bool | None: """Return true if lock is locked.""" return self._device.car_state.get("arm") - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the car.""" self._account.api.set_car_state(self._device.device_id, "arm", True) - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the car.""" self._account.api.set_car_state(self._device.device_id, "arm", False) diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json index d08f276e770..68786ecf341 100644 --- a/homeassistant/components/startca/manifest.json +++ b/homeassistant/components/startca/manifest.json @@ -2,7 +2,7 @@ "domain": "startca", "name": "Start.ca", "documentation": "https://www.home-assistant.io/integrations/startca", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 338b0a80fb6..7ed1f0a3610 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Steam integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import steam @@ -125,7 +126,7 @@ class SteamFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): import_config[CONF_ACCOUNT] = import_config[CONF_ACCOUNTS][0] return await self.async_step_user(import_config) - async def async_step_reauth(self, user_input: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/steam_online/translations/el.json b/homeassistant/components/steam_online/translations/el.json index 0f598dbc395..02405dc0215 100644 --- a/homeassistant/components/steam_online/translations/el.json +++ b/homeassistant/components/steam_online/translations/el.json @@ -25,6 +25,9 @@ } }, "options": { + "error": { + "unauthorized": "\u03a0\u03b5\u03c1\u03b9\u03bf\u03c1\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7 \u03bb\u03af\u03c3\u03c4\u03b1 \u03c6\u03af\u03bb\u03c9\u03bd: \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7 \u03b3\u03b9\u03b1 \u03c4\u03bf \u03c0\u03ce\u03c2 \u03bd\u03b1 \u03b4\u03b5\u03af\u03c4\u03b5 \u03cc\u03bb\u03bf\u03c5\u03c2 \u03c4\u03bf\u03c5\u03c2 \u03ac\u03bb\u03bb\u03bf\u03c5\u03c2 \u03c6\u03af\u03bb\u03bf\u03c5\u03c2" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/steam_online/translations/es.json b/homeassistant/components/steam_online/translations/es.json index 9636fc04a54..26ee994acde 100644 --- a/homeassistant/components/steam_online/translations/es.json +++ b/homeassistant/components/steam_online/translations/es.json @@ -7,10 +7,12 @@ "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "invalid_account": "ID de la cuenta inv\u00e1lida", - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { + "description": "La integraci\u00f3n de Steam debe volver a autenticarse manualmente \n\n Puede encontrar su clave aqu\u00ed: {api_key_url}", "title": "Volver a autenticar la integraci\u00f3n" }, "user": { @@ -23,6 +25,9 @@ } }, "options": { + "error": { + "unauthorized": "Lista de amigos restringida: Por favor, consulte la documentaci\u00f3n sobre c\u00f3mo ver a todos los dem\u00e1s amigos" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index 057645a1d4c..4ea50e5c7de 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/steamist", - "requirements": ["aiosteamist==0.3.1", "discovery30303==0.2.1"], + "requirements": ["aiosteamist==0.3.2", "discovery30303==0.2.1"], "codeowners": ["@bdraco"], "iot_class": "local_polling", "dhcp": [ diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 895bdaf3201..b842eb7fb78 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -16,7 +16,9 @@ to always keep workers active. """ from __future__ import annotations +import asyncio from collections.abc import Callable, Mapping +import copy import logging import re import secrets @@ -37,6 +39,7 @@ from .const import ( ATTR_ENDPOINTS, ATTR_SETTINGS, ATTR_STREAMS, + CONF_EXTRA_PART_WAIT_TIME, CONF_LL_HLS, CONF_PART_DURATION, CONF_RTSP_TRANSPORT, @@ -61,8 +64,11 @@ from .diagnostics import Diagnostics from .hls import HlsStreamOutput, async_setup_hls __all__ = [ + "ATTR_SETTINGS", + "CONF_EXTRA_PART_WAIT_TIME", "CONF_RTSP_TRANSPORT", "CONF_USE_WALLCLOCK_AS_TIMESTAMPS", + "DOMAIN", "FORMAT_CONTENT_TYPE", "HLS_PROVIDER", "OUTPUT_FORMATS", @@ -90,7 +96,7 @@ def redact_credentials(data: str) -> str: def create_stream( hass: HomeAssistant, stream_source: str, - options: dict[str, str | bool], + options: Mapping[str, str | bool | float], stream_label: str | None = None, ) -> Stream: """Create a stream with the specified identfier based on the source url. @@ -100,11 +106,35 @@ def create_stream( The stream_label is a string used as an additional message in logging. """ + + def convert_stream_options( + hass: HomeAssistant, stream_options: Mapping[str, str | bool | float] + ) -> tuple[dict[str, str], StreamSettings]: + """Convert options from stream options into PyAV options and stream settings.""" + stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS]) + pyav_options: dict[str, str] = {} + try: + STREAM_OPTIONS_SCHEMA(stream_options) + except vol.Invalid as exc: + raise HomeAssistantError("Invalid stream options") from exc + + if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME): + stream_settings.hls_part_timeout += extra_wait_time + if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): + assert isinstance(rtsp_transport, str) + # The PyAV options currently match the stream CONF constants, but this + # will not necessarily always be the case, so they are hard coded here + pyav_options["rtsp_transport"] = rtsp_transport + if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + pyav_options["use_wallclock_as_timestamps"] = "1" + + return pyav_options, stream_settings + if DOMAIN not in hass.config.components: raise HomeAssistantError("Stream integration is not set up.") - # Convert extra stream options into PyAV options - pyav_options = convert_stream_options(options) + # Convert extra stream options into PyAV options and stream settings + pyav_options, stream_settings = convert_stream_options(hass, options) # For RTSP streams, prefer TCP if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": pyav_options = { @@ -114,7 +144,11 @@ def create_stream( } stream = Stream( - hass, stream_source, options=pyav_options, stream_label=stream_label + hass, + stream_source, + pyav_options=pyav_options, + stream_settings=stream_settings, + stream_label=stream_label, ) hass.data[DOMAIN][ATTR_STREAMS].append(stream) return stream @@ -206,13 +240,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Setup Recorder async_setup_recorder(hass) - @callback - def shutdown(event: Event) -> None: + async def shutdown(event: Event) -> None: """Stop all stream workers.""" for stream in hass.data[DOMAIN][ATTR_STREAMS]: stream.keepalive = False - stream.stop() - _LOGGER.info("Stopped stream workers") + if awaitables := [ + asyncio.create_task(stream.stop()) + for stream in hass.data[DOMAIN][ATTR_STREAMS] + ]: + await asyncio.wait(awaitables) + _LOGGER.debug("Stopped stream workers") hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) @@ -226,16 +263,19 @@ class Stream: self, hass: HomeAssistant, source: str, - options: dict[str, str], + pyav_options: dict[str, str], + stream_settings: StreamSettings, stream_label: str | None = None, ) -> None: """Initialize a stream.""" self.hass = hass self.source = source - self.options = options + self.pyav_options = pyav_options + self._stream_settings = stream_settings self._stream_label = stream_label self.keepalive = False self.access_token: str | None = None + self._start_stop_lock = asyncio.Lock() self._thread: threading.Thread | None = None self._thread_quit = threading.Event() self._outputs: dict[str, StreamOutput] = {} @@ -271,29 +311,30 @@ class Stream: """Add provider output stream.""" if not (provider := self._outputs.get(fmt)): - @callback - def idle_callback() -> None: + async def idle_callback() -> None: if ( not self.keepalive or fmt == RECORDER_PROVIDER ) and fmt in self._outputs: - self.remove_provider(self._outputs[fmt]) + await self.remove_provider(self._outputs[fmt]) self.check_idle() provider = PROVIDERS[fmt]( - self.hass, IdleTimer(self.hass, timeout, idle_callback) + self.hass, + IdleTimer(self.hass, timeout, idle_callback), + self._stream_settings, ) self._outputs[fmt] = provider return provider - def remove_provider(self, provider: StreamOutput) -> None: + async def remove_provider(self, provider: StreamOutput) -> None: """Remove provider output stream.""" if provider.name in self._outputs: self._outputs[provider.name].cleanup() del self._outputs[provider.name] if not self._outputs: - self.stop() + await self.stop() def check_idle(self) -> None: """Reset access token if all providers are idle.""" @@ -316,9 +357,14 @@ class Stream: if self._update_callback: self._update_callback() - def start(self) -> None: - """Start a stream.""" - if self._thread is None or not self._thread.is_alive(): + async def start(self) -> None: + """Start a stream. + + Uses an asyncio.Lock to avoid conflicts with _stop(). + """ + async with self._start_stop_lock: + if self._thread and self._thread.is_alive(): + return if self._thread is not None: # The thread must have crashed/exited. Join to clean up the # previous thread. @@ -329,7 +375,7 @@ class Stream: target=self._run_worker, ) self._thread.start() - self._logger.info( + self._logger.debug( "Started stream: %s", redact_credentials(str(self.source)) ) @@ -359,7 +405,8 @@ class Stream: try: stream_worker( self.source, - self.options, + self.pyav_options, + self._stream_settings, stream_state, self._keyframe_converter, self._thread_quit, @@ -394,33 +441,39 @@ class Stream: redact_credentials(str(self.source)), ) - @callback - def worker_finished() -> None: + async def worker_finished() -> None: # The worker is no checking availability of the stream and can no longer track # availability so mark it as available, otherwise the frontend may not be able to # interact with the stream. if not self.available: self._async_update_state(True) + # We can call remove_provider() sequentially as the wrapped _stop() function + # which blocks internally is only called when the last provider is removed. for provider in self.outputs().values(): - self.remove_provider(provider) + await self.remove_provider(provider) - self.hass.loop.call_soon_threadsafe(worker_finished) + self.hass.create_task(worker_finished()) - def stop(self) -> None: + async def stop(self) -> None: """Remove outputs and access token.""" self._outputs = {} self.access_token = None if not self.keepalive: - self._stop() + await self._stop() - def _stop(self) -> None: - """Stop worker thread.""" - if self._thread is not None: + async def _stop(self) -> None: + """Stop worker thread. + + Uses an asyncio.Lock to avoid conflicts with start(). + """ + async with self._start_stop_lock: + if self._thread is None: + return self._thread_quit.set() - self._thread.join() + await self.hass.async_add_executor_job(self._thread.join) self._thread = None - self._logger.info( + self._logger.debug( "Stopped stream: %s", redact_credentials(str(self.source)) ) @@ -448,8 +501,7 @@ class Stream: ) recorder.video_path = video_path - self.start() - self._logger.debug("Started a stream recording of %s seconds", duration) + await self.start() # Take advantage of lookback hls: HlsStreamOutput = cast(HlsStreamOutput, self.outputs().get(HLS_PROVIDER)) @@ -457,7 +509,10 @@ class Stream: num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS) # Wait for latest segment, then add the lookback await hls.recv() - recorder.prepend(list(hls.get_segments())[-num_segments:]) + recorder.prepend(list(hls.get_segments())[-num_segments - 1 : -1]) + + self._logger.debug("Started a stream recording of %s seconds", duration) + await recorder.async_record() async def async_get_image( self, @@ -473,7 +528,7 @@ class Stream: """ self.add_provider(HLS_PROVIDER) - self.start() + await self.start() return await self._keyframe_converter.async_get_image( width=width, height=height ) @@ -492,22 +547,6 @@ STREAM_OPTIONS_SCHEMA: Final = vol.Schema( { vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): bool, + vol.Optional(CONF_EXTRA_PART_WAIT_TIME): cv.positive_float, } ) - - -def convert_stream_options(stream_options: dict[str, str | bool]) -> dict[str, str]: - """Convert options from stream options into PyAV options.""" - pyav_options: dict[str, str] = {} - try: - STREAM_OPTIONS_SCHEMA(stream_options) - except vol.Invalid as exc: - raise HomeAssistantError("Invalid stream options") from exc - - if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): - assert isinstance(rtsp_transport, str) - pyav_options["rtsp_transport"] = rtsp_transport - if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): - pyav_options["use_wallclock_as_timestamps"] = "1" - - return pyav_options diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index f8c9ba85d59..35af633435e 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -53,3 +53,4 @@ RTSP_TRANSPORTS = { "http": "HTTP", } CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" +CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time" diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 8c0b867752e..09d9a9d5031 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio from collections import deque -from collections.abc import Iterable +from collections.abc import Callable, Coroutine, Iterable import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from aiohttp import web import async_timeout @@ -118,6 +118,10 @@ class Segment: if self.hls_playlist_complete: return self.hls_playlist_template[0] if not self.hls_playlist_template: + # Logically EXT-X-DISCONTINUITY makes sense above the parts, but Apple's + # media stream validator seems to only want it before the segment + if last_stream_id != self.stream_id: + self.hls_playlist_template.append("#EXT-X-DISCONTINUITY") # This is a placeholder where the rendered parts will be inserted self.hls_playlist_template.append("{}") if render_parts: @@ -133,22 +137,19 @@ class Segment: # the first element to avoid an extra newline when we don't render any parts. # Append an empty string to create a trailing newline when we do render parts self.hls_playlist_parts.append("") - self.hls_playlist_template = [] - # Logically EXT-X-DISCONTINUITY would make sense above the parts, but Apple's - # media stream validator seems to only want it before the segment - if last_stream_id != self.stream_id: - self.hls_playlist_template.append("#EXT-X-DISCONTINUITY") + self.hls_playlist_template = ( + [] if last_stream_id == self.stream_id else ["#EXT-X-DISCONTINUITY"] + ) # Add the remaining segment metadata + # The placeholder goes on the same line as the next element self.hls_playlist_template.extend( [ - "#EXT-X-PROGRAM-DATE-TIME:" + "{}#EXT-X-PROGRAM-DATE-TIME:" + self.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", f"#EXTINF:{self.duration:.3f},\n./segment/{self.sequence}.m4s", ] ) - # The placeholder now goes on the same line as the first element - self.hls_playlist_template[0] = "{}" + self.hls_playlist_template[0] # Store intermediate playlist data in member variables for reuse self.hls_playlist_template = ["\n".join(self.hls_playlist_template)] @@ -192,7 +193,10 @@ class IdleTimer: """ def __init__( - self, hass: HomeAssistant, timeout: int, idle_callback: CALLBACK_TYPE + self, + hass: HomeAssistant, + timeout: int, + idle_callback: Callable[[], Coroutine[Any, Any, None]], ) -> None: """Initialize IdleTimer.""" self._hass = hass @@ -219,11 +223,12 @@ class IdleTimer: if self._unsub is not None: self._unsub() + @callback def fire(self, _now: datetime.datetime) -> None: """Invoke the idle timeout callback, called when the alarm fires.""" self.idle = True self._unsub = None - self._callback() + self._hass.async_create_task(self._callback()) class StreamOutput: @@ -233,11 +238,13 @@ class StreamOutput: self, hass: HomeAssistant, idle_timer: IdleTimer, + stream_settings: StreamSettings, deque_maxlen: int | None = None, ) -> None: """Initialize a stream output.""" self._hass = hass self.idle_timer = idle_timer + self.stream_settings = stream_settings self._event = asyncio.Event() self._part_event = asyncio.Event() self._segments: deque[Segment] = deque(maxlen=deque_maxlen) @@ -320,7 +327,6 @@ class StreamOutput: """Handle cleanup.""" self._event.set() self.idle_timer.clear() - self._segments = deque(maxlen=self._segments.maxlen) class StreamView(HomeAssistantView): @@ -349,7 +355,7 @@ class StreamView(HomeAssistantView): raise web.HTTPNotFound() # Start worker if not already started - stream.start() + await stream.start() return await self.handle(request, stream, sequence, part_num) diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 23584b59fb9..d3bcbb360a6 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -9,8 +9,6 @@ from aiohttp import web from homeassistant.core import HomeAssistant, callback from .const import ( - ATTR_SETTINGS, - DOMAIN, EXT_X_START_LL_HLS, EXT_X_START_NON_LL_HLS, FORMAT_CONTENT_TYPE, @@ -47,17 +45,26 @@ def async_setup_hls(hass: HomeAssistant) -> str: class HlsStreamOutput(StreamOutput): """Represents HLS Output formats.""" - def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: + def __init__( + self, + hass: HomeAssistant, + idle_timer: IdleTimer, + stream_settings: StreamSettings, + ) -> None: """Initialize HLS output.""" - super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS) - self.stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] - self._target_duration = self.stream_settings.min_segment_duration + super().__init__(hass, idle_timer, stream_settings, deque_maxlen=MAX_SEGMENTS) + self._target_duration = stream_settings.min_segment_duration @property def name(self) -> str: """Return provider name.""" return HLS_PROVIDER + def cleanup(self) -> None: + """Handle cleanup.""" + super().cleanup() + self._segments.clear() + @property def target_duration(self) -> float: """Return the target duration.""" @@ -78,14 +85,20 @@ class HlsStreamOutput(StreamOutput): ) def discontinuity(self) -> None: - """Remove incomplete segment from deque.""" + """Fix incomplete segment at end of deque.""" self._hass.loop.call_soon_threadsafe(self._async_discontinuity) @callback def _async_discontinuity(self) -> None: - """Remove incomplete segment from deque in event loop.""" - if self._segments and not self._segments[-1].complete: - self._segments.pop() + """Fix incomplete segment at end of deque in event loop.""" + # Fill in the segment duration or delete the segment if empty + if self._segments: + if (last_segment := self._segments[-1]).parts: + last_segment.duration = sum( + part.duration for part in last_segment.parts + ) + else: + self._segments.pop() class HlsMasterPlaylistView(StreamView): @@ -117,7 +130,7 @@ class HlsMasterPlaylistView(StreamView): ) -> web.Response: """Return m3u8 playlist.""" track = stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() # Make sure at least two segments are ready (last one may not be complete) if not track.sequences and not await track.recv(): return web.HTTPNotFound() @@ -232,7 +245,7 @@ class HlsPlaylistView(StreamView): track: HlsStreamOutput = cast( HlsStreamOutput, stream.add_provider(HLS_PROVIDER) ) - stream.start() + await stream.start() hls_msn: str | int | None = request.query.get("_HLS_msn") hls_part: str | int | None = request.query.get("_HLS_part") diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index f6e00f7c599..e9411e53224 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "documentation": "https://www.home-assistant.io/integrations/stream", - "requirements": ["PyTurboJPEG==1.6.6", "av==9.2.0"], + "requirements": ["PyTurboJPEG==1.6.6", "ha-av==10.0.0b4"], "dependencies": ["http"], "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], "quality_scale": "internal", diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index ae1c64396c8..b33a5fbbf84 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -1,14 +1,11 @@ """Provide functionality to record stream.""" from __future__ import annotations -from collections import deque from io import BytesIO import logging import os -import threading import av -from av.container import OutputContainer from homeassistant.core import HomeAssistant, callback @@ -17,7 +14,7 @@ from .const import ( RECORDER_PROVIDER, SEGMENT_CONTAINER_FORMAT, ) -from .core import PROVIDERS, IdleTimer, Segment, StreamOutput +from .core import PROVIDERS, IdleTimer, Segment, StreamOutput, StreamSettings _LOGGER = logging.getLogger(__name__) @@ -27,103 +24,18 @@ def async_setup_recorder(hass: HomeAssistant) -> None: """Only here so Provider Registry works.""" -def recorder_save_worker(file_out: str, segments: deque[Segment]) -> None: - """Handle saving stream.""" - - if not segments: - _LOGGER.error("Recording failed to capture anything") - return - - os.makedirs(os.path.dirname(file_out), exist_ok=True) - - pts_adjuster: dict[str, int | None] = {"video": None, "audio": None} - output: OutputContainer | None = None - output_v = None - output_a = None - - last_stream_id = None - # The running duration of processed segments. Note that this is in av.time_base - # units which seem to be defined inversely to how stream time_bases are defined - running_duration = 0 - - last_sequence = float("-inf") - for segment in segments: - # Because the stream_worker is in a different thread from the record service, - # the lookback segments may still have some overlap with the recorder segments - if segment.sequence <= last_sequence: - continue - last_sequence = segment.sequence - - # Open segment - source = av.open( - BytesIO(segment.init + segment.get_data()), - "r", - format=SEGMENT_CONTAINER_FORMAT, - ) - # Skip this segment if it doesn't have data - if source.duration is None: - source.close() - continue - source_v = source.streams.video[0] - source_a = source.streams.audio[0] if len(source.streams.audio) > 0 else None - - # Create output on first segment - if not output: - output = av.open( - file_out, - "w", - format=RECORDER_CONTAINER_FORMAT, - container_options={ - "video_track_timescale": str(int(1 / source_v.time_base)) - }, - ) - - # Add output streams if necessary - if not output_v: - output_v = output.add_stream(template=source_v) - context = output_v.codec_context - context.flags |= "GLOBAL_HEADER" - if source_a and not output_a: - output_a = output.add_stream(template=source_a) - - # Recalculate pts adjustments on first segment and on any discontinuity - # We are assuming time base is the same across all discontinuities - if last_stream_id != segment.stream_id: - last_stream_id = segment.stream_id - pts_adjuster["video"] = int( - (running_duration - source.start_time) - / (av.time_base * source_v.time_base) - ) - if source_a: - pts_adjuster["audio"] = int( - (running_duration - source.start_time) - / (av.time_base * source_a.time_base) - ) - - # Remux video - for packet in source.demux(): - if packet.dts is None: - continue - packet.pts += pts_adjuster[packet.stream.type] - packet.dts += pts_adjuster[packet.stream.type] - packet.stream = output_v if packet.stream.type == "video" else output_a - output.mux(packet) - - running_duration += source.duration - source.start_time - - source.close() - - if output is not None: - output.close() - - @PROVIDERS.register(RECORDER_PROVIDER) class RecorderOutput(StreamOutput): - """Represents HLS Output formats.""" + """Represents the Recorder Output format.""" - def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: + def __init__( + self, + hass: HomeAssistant, + idle_timer: IdleTimer, + stream_settings: StreamSettings, + ) -> None: """Initialize recorder output.""" - super().__init__(hass, idle_timer) + super().__init__(hass, idle_timer, stream_settings) self.video_path: str @property @@ -136,13 +48,119 @@ class RecorderOutput(StreamOutput): self._segments.extendleft(reversed(segments)) def cleanup(self) -> None: - """Write recording and clean up.""" - _LOGGER.debug("Starting recorder worker thread") - thread = threading.Thread( - name="recorder_save_worker", - target=recorder_save_worker, - args=(self.video_path, self._segments.copy()), - ) - thread.start() - + """Handle cleanup.""" + self.idle_timer.idle = True super().cleanup() + + async def async_record(self) -> None: + """Handle saving stream.""" + + os.makedirs(os.path.dirname(self.video_path), exist_ok=True) + + pts_adjuster: dict[str, int | None] = {"video": None, "audio": None} + output: av.container.OutputContainer | None = None + output_v = None + output_a = None + + last_stream_id = -1 + # The running duration of processed segments. Note that this is in av.time_base + # units which seem to be defined inversely to how stream time_bases are defined + running_duration = 0 + + last_sequence = float("-inf") + + def write_segment(segment: Segment) -> None: + """Write a segment to output.""" + nonlocal output, output_v, output_a, last_stream_id, running_duration, last_sequence + # Because the stream_worker is in a different thread from the record service, + # the lookback segments may still have some overlap with the recorder segments + if segment.sequence <= last_sequence: + return + last_sequence = segment.sequence + + # Open segment + source = av.open( + BytesIO(segment.init + segment.get_data()), + "r", + format=SEGMENT_CONTAINER_FORMAT, + ) + # Skip this segment if it doesn't have data + if source.duration is None: + source.close() + return + source_v = source.streams.video[0] + source_a = ( + source.streams.audio[0] if len(source.streams.audio) > 0 else None + ) + + # Create output on first segment + if not output: + output = av.open( + self.video_path + ".tmp", + "w", + format=RECORDER_CONTAINER_FORMAT, + container_options={ + "video_track_timescale": str(int(1 / source_v.time_base)) + }, + ) + + # Add output streams if necessary + if not output_v: + output_v = output.add_stream(template=source_v) + context = output_v.codec_context + context.flags |= "GLOBAL_HEADER" + if source_a and not output_a: + output_a = output.add_stream(template=source_a) + + # Recalculate pts adjustments on first segment and on any discontinuity + # We are assuming time base is the same across all discontinuities + if last_stream_id != segment.stream_id: + last_stream_id = segment.stream_id + pts_adjuster["video"] = int( + (running_duration - source.start_time) + / (av.time_base * source_v.time_base) + ) + if source_a: + pts_adjuster["audio"] = int( + (running_duration - source.start_time) + / (av.time_base * source_a.time_base) + ) + + # Remux video + for packet in source.demux(): + if packet.dts is None: + continue + packet.pts += pts_adjuster[packet.stream.type] + packet.dts += pts_adjuster[packet.stream.type] + packet.stream = output_v if packet.stream.type == "video" else output_a + output.mux(packet) + + running_duration += source.duration - source.start_time + + source.close() + + # Write lookback segments + while len(self._segments) > 1: # The last segment is in progress + await self._hass.async_add_executor_job( + write_segment, self._segments.popleft() + ) + # Make sure the first segment has been added + if not self._segments: + await self.recv() + # Write segments as soon as they are completed + while not self.idle: + await self.recv() + await self._hass.async_add_executor_job( + write_segment, self._segments.popleft() + ) + # Write remaining segments + # Should only have 0 or 1 segments, but loop through just in case + while self._segments: + await self._hass.async_add_executor_job( + write_segment, self._segments.popleft() + ) + if output is None: + _LOGGER.error("Recording failed to capture anything") + else: + output.close() + os.rename(self.video_path + ".tmp", self.video_path) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index f8d12c1cb44..e46d83542f7 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -16,9 +16,7 @@ from homeassistant.core import HomeAssistant from . import redact_credentials from .const import ( - ATTR_SETTINGS, AUDIO_CODECS, - DOMAIN, HLS_PROVIDER, MAX_MISSING_DTS, MAX_TIMESTAMP_GAP, @@ -87,7 +85,7 @@ class StreamState: # simple to check for discontinuity at output time, and to determine # the discontinuity sequence number. self._stream_id += 1 - # Call discontinuity to remove incomplete segment from the HLS output + # Call discontinuity to fix incomplete segment in HLS output if hls_output := self._outputs_callback().get(HLS_PROVIDER): cast(HlsStreamOutput, hls_output).discontinuity() @@ -110,7 +108,9 @@ class StreamMuxer: hass: HomeAssistant, video_stream: av.video.VideoStream, audio_stream: av.audio.stream.AudioStream | None, + audio_bsf: av.BitStreamFilterContext | None, stream_state: StreamState, + stream_settings: StreamSettings, ) -> None: """Initialize StreamMuxer.""" self._hass = hass @@ -119,6 +119,7 @@ class StreamMuxer: self._av_output: av.container.OutputContainer = None self._input_video_stream: av.video.VideoStream = video_stream self._input_audio_stream: av.audio.stream.AudioStream | None = audio_stream + self._audio_bsf = audio_bsf self._output_video_stream: av.video.VideoStream = None self._output_audio_stream: av.audio.stream.AudioStream | None = None self._segment: Segment | None = None @@ -126,7 +127,7 @@ class StreamMuxer: self._memory_file_pos: int = cast(int, None) self._part_start_dts: int = cast(int, None) self._part_has_keyframe = False - self._stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] + self._stream_settings = stream_settings self._stream_state = stream_state self._start_time = datetime.datetime.utcnow() @@ -193,7 +194,9 @@ class StreamMuxer: # Check if audio is requested output_astream = None if input_astream: - output_astream = container.add_stream(template=input_astream) + output_astream = container.add_stream( + template=self._audio_bsf or input_astream + ) return container, output_vstream, output_astream def reset(self, video_dts: int) -> None: @@ -235,6 +238,12 @@ class StreamMuxer: self._part_has_keyframe |= packet.is_keyframe elif packet.stream == self._input_audio_stream: + if self._audio_bsf: + self._audio_bsf.send(packet) + while packet := self._audio_bsf.recv(): + packet.stream = self._output_audio_stream + self._av_output.mux(packet) + return packet.stream = self._output_audio_stream self._av_output.mux(packet) @@ -356,12 +365,6 @@ class PeekIterator(Iterator): """Return and consume the next item available.""" return self._next() - def replace_underlying_iterator(self, new_iterator: Iterator) -> None: - """Replace the underlying iterator while preserving the buffer.""" - self._iterator = new_iterator - if not self._buffer: - self._next = self._iterator.__next__ - def _pop_buffer(self) -> av.Packet: """Consume items from the buffer until exhausted.""" if self._buffer: @@ -423,10 +426,12 @@ def is_keyframe(packet: av.Packet) -> Any: return packet.is_keyframe -def unsupported_audio(packets: Iterator[av.Packet], audio_stream: Any) -> bool: - """Detect ADTS AAC, which is not supported by pyav.""" +def get_audio_bitstream_filter( + packets: Iterator[av.Packet], audio_stream: Any +) -> av.BitStreamFilterContext | None: + """Return the aac_adtstoasc bitstream filter if ADTS AAC is detected.""" if not audio_stream: - return False + return None for count, packet in enumerate(packets): if count >= PACKETS_TO_WAIT_FOR_AUDIO: # Some streams declare an audio stream and never send any packets @@ -437,27 +442,33 @@ def unsupported_audio(packets: Iterator[av.Packet], audio_stream: Any) -> bool: if audio_stream.codec.name == "aac" and packet.size > 2: with memoryview(packet) as packet_view: if packet_view[0] == 0xFF and packet_view[1] & 0xF0 == 0xF0: - _LOGGER.warning("ADTS AAC detected - disabling audio stream") - return True + _LOGGER.debug( + "ADTS AAC detected. Adding aac_adtstoaac bitstream filter" + ) + bsf = av.BitStreamFilter("aac_adtstoasc") + bsf_context = bsf.create() + bsf_context.set_input_stream(audio_stream) + return bsf_context break - return False + return None def stream_worker( source: str, - options: dict[str, str], + pyav_options: dict[str, str], + stream_settings: StreamSettings, stream_state: StreamState, keyframe_converter: KeyFrameConverter, quit_event: Event, ) -> None: """Handle consuming streams.""" - if av.library_versions["libavformat"][0] >= 59 and "stimeout" in options: + if av.library_versions["libavformat"][0] >= 59 and "stimeout" in pyav_options: # the stimeout option was renamed to timeout as of ffmpeg 5.0 - options["timeout"] = options["stimeout"] - del options["stimeout"] + pyav_options["timeout"] = pyav_options["stimeout"] + del pyav_options["stimeout"] try: - container = av.open(source, options=options, timeout=SOURCE_TIMEOUT) + container = av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT) except av.AVError as err: raise StreamWorkerError( f"Error opening stream ({err.type}, {err.strerror}) {redact_credentials(str(source))}" @@ -473,13 +484,12 @@ def stream_worker( audio_stream = None if audio_stream and audio_stream.name not in AUDIO_CODECS: audio_stream = None - # These formats need aac_adtstoasc bitstream filter, but auto_bsf not - # compatible with empty_moov and manual bitstream filters not in PyAV - if container.format.name in {"hls", "mpegts"}: - audio_stream = None # Some audio streams do not have a profile and throw errors when remuxing if audio_stream and audio_stream.profile is None: audio_stream = None + # Disable ll-hls for hls inputs + if container.format.name == "hls": + stream_settings.ll_hls = False stream_state.diagnostics.set_value("container_format", container.format.name) stream_state.diagnostics.set_value("video_codec", video_stream.name) if audio_stream: @@ -501,12 +511,8 @@ def stream_worker( # Use a peeking iterator to peek into the start of the stream, ensuring # everything looks good, then go back to the start when muxing below. try: - if audio_stream and unsupported_audio(container_packets.peek(), audio_stream): - audio_stream = None - container_packets.replace_underlying_iterator( - filter(dts_validator.is_valid, container.demux(video_stream)) - ) - + # Get the required bitstream filter + audio_bsf = get_audio_bitstream_filter(container_packets.peek(), audio_stream) # Advance to the first keyframe for muxing, then rewind so the muxing # loop below can consume. first_keyframe = next( @@ -535,7 +541,14 @@ def stream_worker( "Error demuxing stream while finding first packet: %s" % str(ex) ) from ex - muxer = StreamMuxer(stream_state.hass, video_stream, audio_stream, stream_state) + muxer = StreamMuxer( + stream_state.hass, + video_stream, + audio_stream, + audio_bsf, + stream_state, + stream_settings, + ) muxer.reset(start_dts) # Mux the first keyframe, then proceed through the rest of the packets diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 788b6f04fd5..79c412c8f85 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Subaru integration.""" +from __future__ import annotations + from datetime import datetime import logging @@ -83,7 +85,9 @@ class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 3c619690e96..bf9d1a8793b 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -1,5 +1,6 @@ """Support for Subaru door locks.""" import logging +from typing import Any import voluptuous as vol @@ -68,7 +69,7 @@ class SubaruLock(LockEntity): self._attr_unique_id = f"{vin}_door_locks" self._attr_device_info = get_device_info(vehicle_info) - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Send the lock command.""" _LOGGER.debug("Locking doors for: %s", self.car_name) await async_call_remote_service( @@ -77,7 +78,7 @@ class SubaruLock(LockEntity): self.vehicle_info, ) - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Send the unlock command.""" _LOGGER.debug("Unlocking doors for: %s", self.car_name) await async_call_remote_service( diff --git a/homeassistant/components/subaru/translations/bg.json b/homeassistant/components/subaru/translations/bg.json index 00b879eca0d..9031d8b47ce 100644 --- a/homeassistant/components/subaru/translations/bg.json +++ b/homeassistant/components/subaru/translations/bg.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "error": { + "bad_pin_format": "\u041f\u0418\u041d \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 4 \u0446\u0438\u0444\u0440\u0438", + "incorrect_validation_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d \u043a\u043e\u0434 \u0437\u0430 \u0432\u0430\u043b\u0438\u0434\u0438\u0440\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/subaru/translations/es.json b/homeassistant/components/subaru/translations/es.json index 170983e8df4..7b3a32c0213 100644 --- a/homeassistant/components/subaru/translations/es.json +++ b/homeassistant/components/subaru/translations/es.json @@ -6,9 +6,12 @@ }, "error": { "bad_pin_format": "El PIN debe tener 4 d\u00edgitos", + "bad_validation_code_format": "El c\u00f3digo de validaci\u00f3n debe tener 6 d\u00edgitos", "cannot_connect": "No se pudo conectar", "incorrect_pin": "PIN incorrecto", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "incorrect_validation_code": "C\u00f3digo de validaci\u00f3n incorrecto", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "two_factor_request_failed": "La solicitud del c\u00f3digo 2FA fall\u00f3, int\u00e9ntalo de nuevo" }, "step": { "pin": { diff --git a/homeassistant/components/subaru/translations/sv.json b/homeassistant/components/subaru/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/subaru/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sun/translations/es.json b/homeassistant/components/sun/translations/es.json index d8ce466236e..68db12462b1 100644 --- a/homeassistant/components/sun/translations/es.json +++ b/homeassistant/components/sun/translations/es.json @@ -1,4 +1,14 @@ { + "config": { + "abort": { + "single_instance_allowed": "Ya configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "step": { + "user": { + "description": "\u00bfQuiere empezar a configurar?" + } + } + }, "state": { "_": { "above_horizon": "Sobre el horizonte", diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 172f1cadf43..c6c1d9c07db 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging from pprint import pformat +from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.core import HomeAssistant @@ -59,32 +60,32 @@ class SuplaCover(SuplaChannel, CoverEntity): """Representation of a Supla Cover.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. 0 is closed, 100 is open.""" if state := self.channel_data.get("state"): return 100 - state["shut"] return None - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.async_action("REVEAL", percentage=kwargs.get(ATTR_POSITION)) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self.current_cover_position is None: return None return self.current_cover_position == 0 - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.async_action("REVEAL") - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.async_action("SHUT") - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.async_action("STOP") @@ -93,32 +94,32 @@ class SuplaGateDoor(SuplaChannel, CoverEntity): """Representation of a Supla gate door.""" @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the gate is closed or not.""" state = self.channel_data.get("state") if state and "hi" in state: return state.get("hi") return None - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the gate.""" if self.is_closed: await self.async_action("OPEN_CLOSE") - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the gate.""" if not self.is_closed: await self.async_action("OPEN_CLOSE") - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the gate.""" await self.async_action("OPEN_CLOSE") - async def async_toggle(self, **kwargs) -> None: + async def async_toggle(self, **kwargs: Any) -> None: """Toggle the gate.""" await self.async_action("OPEN_CLOSE") @property - def device_class(self): + def device_class(self) -> CoverDeviceClass: """Return the class of this device, from component DEVICE_CLASSES.""" return CoverDeviceClass.GARAGE diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 30f20257e8c..7c4509259ad 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Sure Petcare integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -86,9 +87,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/surepetcare/translations/sv.json b/homeassistant/components/surepetcare/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/surepetcare/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index 5ebc8902d06..d4f16a93ef6 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -1,6 +1,8 @@ """Fan support for switch entities.""" from __future__ import annotations +from typing import Any + from homeassistant.components.fan import FanEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID @@ -53,7 +55,7 @@ class FanSwitch(BaseToggleEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan. diff --git a/homeassistant/components/switch_as_x/translations/cs.json b/homeassistant/components/switch_as_x/translations/cs.json index c521a79e1d9..b7dc53680d9 100644 --- a/homeassistant/components/switch_as_x/translations/cs.json +++ b/homeassistant/components/switch_as_x/translations/cs.json @@ -8,5 +8,5 @@ } } }, - "title": "Zm\u011bna typy vyp\u00edna\u010de" + "title": "Zm\u011bna typu vyp\u00edna\u010de" } \ No newline at end of file diff --git a/homeassistant/components/switch_as_x/translations/es.json b/homeassistant/components/switch_as_x/translations/es.json index 7e91d3217a4..ad0f9f52bbf 100644 --- a/homeassistant/components/switch_as_x/translations/es.json +++ b/homeassistant/components/switch_as_x/translations/es.json @@ -5,7 +5,8 @@ "data": { "entity_id": "Conmutador", "target_domain": "Nuevo tipo" - } + }, + "description": "Elija un interruptor que desee que aparezca en Home Assistant como luz, cubierta o cualquier otra cosa. El interruptor original se ocultar\u00e1." } } }, diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 0059d655767..68fbd4bd584 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -1,17 +1,14 @@ """Support for Switchbot devices.""" -from asyncio import Lock -import switchbot # pylint: disable=import-error +import switchbot from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SENSOR_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .const import ( ATTR_BOT, ATTR_CURTAIN, - BTLE_LOCK, COMMON_OPTIONS, CONF_RETRY_COUNT, CONF_RETRY_TIMEOUT, @@ -50,12 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Uses BTLE advertisement data, all Switchbot devices in range is stored here. if DATA_COORDINATOR not in hass.data[DOMAIN]: - # Check if asyncio.lock is stored in hass data. - # BTLE has issues with multiple connections, - # so we use a lock to ensure that only one API request is reaching it at a time: - if BTLE_LOCK not in hass.data[DOMAIN]: - hass.data[DOMAIN][BTLE_LOCK] = Lock() - if COMMON_OPTIONS not in hass.data[DOMAIN]: hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options} @@ -72,7 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api=switchbot, retry_count=hass.data[DOMAIN][COMMON_OPTIONS][CONF_RETRY_COUNT], scan_timeout=hass.data[DOMAIN][COMMON_OPTIONS][CONF_SCAN_TIMEOUT], - api_lock=hass.data[DOMAIN][BTLE_LOCK], ) hass.data[DOMAIN][DATA_COORDINATOR] = coordinator @@ -82,9 +72,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - if not coordinator.last_update_success: - raise ConfigEntryNotReady - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index c3f88e924ea..e2a5a951d1d 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,8 +34,8 @@ async def async_setup_entry( DATA_COORDINATOR ] - if not coordinator.data[entry.unique_id].get("data"): - return + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady async_add_entities( [ diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 70e032414a7..362f3b01ae7 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -1,11 +1,10 @@ """Config flow for Switchbot.""" from __future__ import annotations -from asyncio import Lock import logging from typing import Any -from switchbot import GetSwitchbotDevices # pylint: disable=import-error +from switchbot import GetSwitchbotDevices import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow @@ -14,7 +13,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( - BTLE_LOCK, CONF_RETRY_COUNT, CONF_RETRY_TIMEOUT, CONF_SCAN_TIMEOUT, @@ -30,10 +28,10 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def _btle_connect() -> dict: +async def _btle_connect() -> dict: """Scan for BTLE advertisement data.""" - switchbot_devices = GetSwitchbotDevices().discover() + switchbot_devices = await GetSwitchbotDevices().discover() if not switchbot_devices: raise NotConnectedError("Failed to discover switchbot") @@ -52,14 +50,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): # store asyncio.lock in hass data if not present. if DOMAIN not in self.hass.data: self.hass.data.setdefault(DOMAIN, {}) - if BTLE_LOCK not in self.hass.data[DOMAIN]: - self.hass.data[DOMAIN][BTLE_LOCK] = Lock() - - connect_lock = self.hass.data[DOMAIN][BTLE_LOCK] # Discover switchbots nearby. - async with connect_lock: - _btle_adv_data = await self.hass.async_add_executor_job(_btle_connect) + _btle_adv_data = await _btle_connect() return _btle_adv_data diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 8ca7fadf41c..b1587e97c10 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -22,5 +22,4 @@ CONF_SCAN_TIMEOUT = "scan_timeout" # Data DATA_COORDINATOR = "coordinator" -BTLE_LOCK = "btle_lock" COMMON_OPTIONS = "common_options" diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index e901cc539ea..e8e2e240dc6 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -1,11 +1,10 @@ """Provides the switchbot DataUpdateCoordinator.""" from __future__ import annotations -from asyncio import Lock from datetime import timedelta import logging -import switchbot # pylint: disable=import-error +import switchbot from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,7 +25,6 @@ class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): api: switchbot, retry_count: int, scan_timeout: int, - api_lock: Lock, ) -> None: """Initialize global switchbot data updater.""" self.switchbot_api = api @@ -39,20 +37,12 @@ class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name=DOMAIN, update_interval=self.update_interval ) - self.api_lock = api_lock - - def _update_data(self) -> dict | None: - """Fetch device states from switchbot api.""" - - return self.switchbot_data.discover( - retry=self.retry_count, scan_timeout=self.scan_timeout - ) - async def _async_update_data(self) -> dict | None: """Fetch data from switchbot.""" - async with self.api_lock: - switchbot_data = await self.hass.async_add_executor_job(self._update_data) + switchbot_data = await self.switchbot_data.discover( + retry=self.retry_count, scan_timeout=self.scan_timeout + ) if not switchbot_data: raise UpdateFailed("Unable to fetch switchbot services data") diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 9f265d696ad..9223217c173 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from switchbot import SwitchbotCurtain # pylint: disable=import-error +from switchbot import SwitchbotCurtain from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -16,6 +16,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -36,6 +37,9 @@ async def async_setup_entry( DATA_COORDINATOR ] + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady + async_add_entities( [ SwitchBotCurtainEntity( @@ -94,44 +98,30 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Open the curtain.""" _LOGGER.debug("Switchbot to open curtain %s", self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.open) - ) + self._last_run_success = bool(await self._device.open()) + self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the curtain.""" _LOGGER.debug("Switchbot to close the curtain %s", self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.close) - ) + self._last_run_success = bool(await self._device.close()) + self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the moving of this device.""" _LOGGER.debug("Switchbot to stop %s", self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.stop) - ) + self._last_run_success = bool(await self._device.stop()) + self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" position = kwargs.get(ATTR_POSITION) _LOGGER.debug("Switchbot to move at %d %s", position, self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job( - self._device.set_position, position - ) - ) + self._last_run_success = bool(await self._device.set_position(position)) + self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 7a16225dcbb..cb485ffd8a5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -2,7 +2,7 @@ "domain": "switchbot", "name": "SwitchBot", "documentation": "https://www.home-assistant.io/integrations/switchbot", - "requirements": ["PySwitchbot==0.13.3"], + "requirements": ["PySwitchbot==0.14.0"], "config_flow": true, "codeowners": ["@danielhiversen", "@RenierM26"], "iot_class": "local_polling", diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 1ee0276b7ee..759a504d19a 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -53,8 +54,8 @@ async def async_setup_entry( DATA_COORDINATOR ] - if not coordinator.data[entry.unique_id].get("data"): - return + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady async_add_entities( [ diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index b5507594521..404a92eda82 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -4,12 +4,13 @@ from __future__ import annotations import logging from typing import Any -from switchbot import Switchbot # pylint: disable=import-error +from switchbot import Switchbot from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import entity_platform from homeassistant.helpers.restore_state import RestoreEntity @@ -32,6 +33,9 @@ async def async_setup_entry( DATA_COORDINATOR ] + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady + async_add_entities( [ SwitchBotBotEntity( @@ -80,25 +84,19 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): """Turn device on.""" _LOGGER.info("Turn Switchbot bot on %s", self._mac) - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.turn_on) - ) - if self._last_run_success: - self._attr_is_on = True - self.async_write_ha_state() + self._last_run_success = bool(await self._device.turn_on()) + if self._last_run_success: + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" _LOGGER.info("Turn Switchbot bot off %s", self._mac) - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.turn_off) - ) - if self._last_run_success: - self._attr_is_on = False - self.async_write_ha_state() + self._last_run_success = bool(await self._device.turn_off()) + if self._last_run_success: + self._attr_is_on = False + self.async_write_ha_state() @property def assumed_state(self) -> bool: diff --git a/homeassistant/components/syncthru/translations/bg.json b/homeassistant/components/syncthru/translations/bg.json index bd8d6d4bf88..f957aba1fe6 100644 --- a/homeassistant/components/syncthru/translations/bg.json +++ b/homeassistant/components/syncthru/translations/bg.json @@ -10,7 +10,8 @@ "step": { "confirm": { "data": { - "name": "\u0418\u043c\u0435" + "name": "\u0418\u043c\u0435", + "url": "URL \u043d\u0430 \u0443\u0435\u0431 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430" } }, "user": { diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index ece38bf7326..e6868491eae 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -1,47 +1,34 @@ """The Synology DSM component.""" from __future__ import annotations -from datetime import timedelta +from itertools import chain import logging -from typing import Any -import async_timeout from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera -from synology_dsm.exceptions import ( - SynologyDSMAPIErrorException, - SynologyDSMLogin2SARequiredException, - SynologyDSMLoginDisabledAccountException, - SynologyDSMLoginFailedException, - SynologyDSMLoginInvalidException, - SynologyDSMLoginPermissionDeniedException, - SynologyDSMRequestException, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL +from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import SynoApi from .const import ( - COORDINATOR_CAMERAS, - COORDINATOR_CENTRAL, - COORDINATOR_SWITCHES, - DEFAULT_SCAN_INTERVAL, DEFAULT_VERIFY_SSL, DOMAIN, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, PLATFORMS, - SIGNAL_CAMERA_SOURCE_CHANGED, - SYNO_API, - SYSTEM_LOADED, - UNDO_UPDATE_LISTENER, + SYNOLOGY_AUTH_FAILED_EXCEPTIONS, + SYNOLOGY_CONNECTION_EXCEPTIONS, ) +from .coordinator import ( + SynologyDSMCameraUpdateCoordinator, + SynologyDSMCentralUpdateCoordinator, + SynologyDSMSwitchUpdateCoordinator, +) +from .models import SynologyDSMData from .service import async_setup_services CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -79,31 +66,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = SynoApi(hass, entry) try: await api.async_setup() - except ( - SynologyDSMLogin2SARequiredException, - SynologyDSMLoginDisabledAccountException, - SynologyDSMLoginInvalidException, - SynologyDSMLoginPermissionDeniedException, - ) as err: + except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err: if err.args[0] and isinstance(err.args[0], dict): details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) else: details = EXCEPTION_UNKNOWN raise ConfigEntryAuthFailed(f"reason: {details}") from err - except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: if err.args[0] and isinstance(err.args[0], dict): details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) else: details = EXCEPTION_UNKNOWN raise ConfigEntryNotReady(details) from err - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.unique_id] = { - UNDO_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener), - SYNO_API: api, - SYSTEM_LOADED: True, - } - # Services await async_setup_services(hass) @@ -114,114 +89,78 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, CONF_MAC: network.macs} ) - async def async_coordinator_update_data_cameras() -> dict[ - str, dict[str, SynoCamera] - ] | None: - """Fetch all camera data from api.""" - if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: - raise UpdateFailed("System not fully loaded") + # These all create executor jobs so we do not gather here + coordinator_central = SynologyDSMCentralUpdateCoordinator(hass, entry, api) + await coordinator_central.async_config_entry_first_refresh() - if SynoSurveillanceStation.CAMERA_API_KEY not in api.dsm.apis: - return None + available_apis = api.dsm.apis - surveillance_station = api.surveillance_station - current_data: dict[str, SynoCamera] = { - camera.id: camera for camera in surveillance_station.get_all_cameras() - } + # The central coordinator needs to be refreshed first since + # the next two rely on data from it + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None = None + if SynoSurveillanceStation.CAMERA_API_KEY in available_apis: + coordinator_cameras = SynologyDSMCameraUpdateCoordinator(hass, entry, api) + await coordinator_cameras.async_config_entry_first_refresh() + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None = None + if ( + SynoSurveillanceStation.INFO_API_KEY in available_apis + and SynoSurveillanceStation.HOME_MODE_API_KEY in available_apis + ): + coordinator_switches = SynologyDSMSwitchUpdateCoordinator(hass, entry, api) + await coordinator_switches.async_config_entry_first_refresh() try: - async with async_timeout.timeout(30): - await hass.async_add_executor_job(surveillance_station.update) - except SynologyDSMAPIErrorException as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err + await coordinator_switches.async_setup() + except SYNOLOGY_CONNECTION_EXCEPTIONS as ex: + raise ConfigEntryNotReady from ex - new_data: dict[str, SynoCamera] = { - camera.id: camera for camera in surveillance_station.get_all_cameras() - } - - for cam_id, cam_data_new in new_data.items(): - if ( - (cam_data_current := current_data.get(cam_id)) is not None - and cam_data_current.live_view.rtsp != cam_data_new.live_view.rtsp - ): - async_dispatcher_send( - hass, - f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{entry.entry_id}_{cam_id}", - cam_data_new.live_view.rtsp, - ) - - return {"cameras": new_data} - - async def async_coordinator_update_data_central() -> None: - """Fetch all device and sensor data from api.""" - try: - await api.async_update() - except Exception as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - return None - - async def async_coordinator_update_data_switches() -> dict[ - str, dict[str, Any] - ] | None: - """Fetch all switch data from api.""" - if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: - raise UpdateFailed("System not fully loaded") - if SynoSurveillanceStation.HOME_MODE_API_KEY not in api.dsm.apis: - return None - - surveillance_station = api.surveillance_station - - return { - "switches": { - "home_mode": await hass.async_add_executor_job( - surveillance_station.get_home_mode_status - ) - } - } - - hass.data[DOMAIN][entry.unique_id][COORDINATOR_CAMERAS] = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{entry.unique_id}_cameras", - update_method=async_coordinator_update_data_cameras, - update_interval=timedelta(seconds=30), + synology_data = SynologyDSMData( + api=api, + coordinator_central=coordinator_central, + coordinator_cameras=coordinator_cameras, + coordinator_switches=coordinator_switches, ) - - hass.data[DOMAIN][entry.unique_id][COORDINATOR_CENTRAL] = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{entry.unique_id}_central", - update_method=async_coordinator_update_data_central, - update_interval=timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), - ) - - hass.data[DOMAIN][entry.unique_id][COORDINATOR_SWITCHES] = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{entry.unique_id}_switches", - update_method=async_coordinator_update_data_switches, - update_interval=timedelta(seconds=30), - ) - + hass.data.setdefault(DOMAIN, {})[entry.unique_id] = synology_data hass.config_entries.async_setup_platforms(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Synology DSM sensors.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - entry_data = hass.data[DOMAIN][entry.unique_id] - entry_data[UNDO_UPDATE_LISTENER]() - await entry_data[SYNO_API].async_unload() + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + await entry_data.api.async_unload() hass.data[DOMAIN].pop(entry.unique_id) - return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove synology_dsm config entry from a device.""" + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + api = data.api + serial = api.information.serial + storage = api.storage + # get_all_cameras does not do I/O + all_cameras: list[SynoCamera] = api.surveillance_station.get_all_cameras() + device_ids = chain( + (camera.id for camera in all_cameras), + storage.volumes_ids, + storage.disks_ids, + storage.volumes_ids, + (SynoSurveillanceStation.INFO_API_KEY,), # Camera home/away + ) + return not device_entry.identifiers.intersection( + ( + (DOMAIN, serial), # Base device + *((DOMAIN, f"{serial}_{id}") for id in device_ids), # Storage and cameras + ) + ) diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index a5c96575307..b5f5effbb8e 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -22,12 +22,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi -from .const import COORDINATOR_CENTRAL, DOMAIN, SYNO_API +from .const import DOMAIN from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) +from .models import SynologyDSMData @dataclass @@ -80,10 +81,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS binary sensor.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - coordinator = data[COORDINATOR_CENTRAL] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + api = data.api + coordinator = data.coordinator_central entities: list[ SynoDSMSecurityBinarySensor diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 58f1a0dfdd7..a1337e672f6 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -17,7 +17,8 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SynoApi -from .const import DOMAIN, SYNO_API +from .const import DOMAIN +from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) @@ -60,10 +61,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" - data = hass.data[DOMAIN][entry.unique_id] - syno_api: SynoApi = data[SYNO_API] - - async_add_entities(SynologyDSMButton(syno_api, button) for button in BUTTONS) + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + async_add_entities(SynologyDSMButton(data.api, button) for button in BUTTONS) class SynologyDSMButton(ButtonEntity): diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 0a6934b45a7..6dac67cf72d 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -25,13 +25,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi from .const import ( CONF_SNAPSHOT_QUALITY, - COORDINATOR_CAMERAS, DEFAULT_SNAPSHOT_QUALITY, DOMAIN, SIGNAL_CAMERA_SOURCE_CHANGED, - SYNO_API, ) from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription +from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -47,23 +46,12 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS cameras.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - - if SynoSurveillanceStation.CAMERA_API_KEY not in api.dsm.apis: - return - - # initial data fetch - coordinator: DataUpdateCoordinator[dict[str, dict[str, SynoCamera]]] = data[ - COORDINATOR_CAMERAS - ] - await coordinator.async_config_entry_first_refresh() - - async_add_entities( - SynoDSMCamera(api, coordinator, camera_id) - for camera_id in coordinator.data["cameras"] - ) + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + if coordinator := data.coordinator_cameras: + async_add_entities( + SynoDSMCamera(data.api, coordinator, camera_id) + for camera_id in coordinator.data["cameras"] + ) class SynoDSMCamera(SynologyDSMBaseEntity, Camera): diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 2ca9cbf3ccf..12bad2954dd 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -16,7 +16,6 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, SynologyDSMException, - SynologyDSMLoginFailedException, SynologyDSMRequestException, ) @@ -32,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback -from .const import CONF_DEVICE_TOKEN, DOMAIN, SYSTEM_LOADED +from .const import CONF_DEVICE_TOKEN, SYNOLOGY_CONNECTION_EXCEPTIONS LOGGER = logging.getLogger(__name__) @@ -72,6 +71,10 @@ class SynoApi: async def async_setup(self) -> None: """Start interacting with the NAS.""" + await self._hass.async_add_executor_job(self._setup) + + def _setup(self) -> None: + """Start interacting with the NAS in the executor.""" self.dsm = SynologyDSM( self._entry.data[CONF_HOST], self._entry.data[CONF_PORT], @@ -82,7 +85,7 @@ class SynoApi: timeout=self._entry.options.get(CONF_TIMEOUT), device_token=self._entry.data.get(CONF_DEVICE_TOKEN), ) - await self._hass.async_add_executor_job(self.dsm.login) + self.dsm.login() # check if surveillance station is used self._with_surveillance_station = bool( @@ -94,10 +97,24 @@ class SynoApi: self._with_surveillance_station, ) - self._async_setup_api_requests() + # check if upgrade is available + try: + self.dsm.upgrade.update() + except SynologyDSMAPIErrorException as ex: + self._with_upgrade = False + LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) - await self._hass.async_add_executor_job(self._fetch_device_configuration) - await self.async_update(first_setup=True) + self._fetch_device_configuration() + + try: + self._update() + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: + LOGGER.debug( + "Connection error during setup of '%s' with exception: %s", + self._entry.unique_id, + err, + ) + raise err @callback def subscribe(self, api_key: str, unique_id: str) -> Callable[[], None]: @@ -117,8 +134,7 @@ class SynoApi: return unsubscribe - @callback - def _async_setup_api_requests(self) -> None: + def _setup_api_requests(self) -> None: """Determine if we should fetch each API, if one entity needs it.""" # Entities not added yet, fetch all if not self._fetching_entities: @@ -217,11 +233,6 @@ class SynoApi: ) self.surveillance_station = self.dsm.surveillance_station - def _set_system_loaded(self, state: bool = False) -> None: - """Set system loaded flag.""" - dsm_device = self._hass.data[DOMAIN].get(self.information.serial) - dsm_device[SYSTEM_LOADED] = state - async def _syno_api_executer(self, api_call: Callable) -> None: """Synology api call wrapper.""" try: @@ -235,12 +246,10 @@ class SynoApi: async def async_reboot(self) -> None: """Reboot NAS.""" await self._syno_api_executer(self.system.reboot) - self._set_system_loaded() async def async_shutdown(self) -> None: """Shutdown NAS.""" await self._syno_api_executer(self.system.shutdown) - self._set_system_loaded() async def async_unload(self) -> None: """Stop interacting with the NAS and prepare for removal from hass.""" @@ -250,30 +259,23 @@ class SynoApi: # ignore API errors during logout pass - async def async_update(self, first_setup: bool = False) -> None: + async def async_update(self) -> None: """Update function for updating API information.""" - LOGGER.debug("Start data update for '%s'", self._entry.unique_id) - self._async_setup_api_requests() try: - await self._hass.async_add_executor_job( - self.dsm.update, self._with_information - ) - except ( - SynologyDSMLoginFailedException, - SynologyDSMRequestException, - SynologyDSMAPIErrorException, - ) as err: + await self._hass.async_add_executor_job(self._update) + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: LOGGER.debug( "Connection error during update of '%s' with exception: %s", self._entry.unique_id, err, ) - - if first_setup: - raise err - LOGGER.warning( "Connection error during update, fallback by reloading the entry" ) await self._hass.config_entries.async_reload(self._entry.entry_id) - return + + def _update(self) -> None: + """Update function for updating API information.""" + LOGGER.debug("Start data update for '%s'", self._entry.unique_id) + self._setup_api_requests() + self.dsm.update(self._with_information) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 256ad5eef8e..89bbc4ae8c2 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Synology DSM integration.""" from __future__ import annotations +from collections.abc import Mapping from ipaddress import ip_address import logging from typing import Any @@ -120,7 +121,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize the synology_dsm config flow.""" self.saved_user_input: dict[str, Any] = {} self.discovered_conf: dict[str, Any] = {} - self.reauth_conf: dict[str, Any] = {} + self.reauth_conf: Mapping[str, Any] = {} self.reauth_reason: str | None = None def _show_form( @@ -299,9 +300,9 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): user_input = {**self.discovered_conf, **user_input} return await self.async_validate_input_create_entry(user_input, step_id=step) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self.reauth_conf = data.copy() + self.reauth_conf = entry_data return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index f716130a5e4..c5c9e590684 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,15 @@ from __future__ import annotations from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED +from synology_dsm.exceptions import ( + SynologyDSMAPIErrorException, + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, + SynologyDSMLoginFailedException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, + SynologyDSMRequestException, +) from homeassistant.const import Platform @@ -15,17 +24,9 @@ PLATFORMS = [ Platform.SWITCH, Platform.UPDATE, ] -COORDINATOR_CAMERAS = "coordinator_cameras" -COORDINATOR_CENTRAL = "coordinator_central" -COORDINATOR_SWITCHES = "coordinator_switches" -SYSTEM_LOADED = "system_loaded" EXCEPTION_DETAILS = "details" EXCEPTION_UNKNOWN = "unknown" -# Entry keys -SYNO_API = "syno_api" -UNDO_UPDATE_LISTENER = "undo_update_listener" - # Configuration CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" @@ -53,3 +54,16 @@ SERVICES = [ SERVICE_REBOOT, SERVICE_SHUTDOWN, ] + +SYNOLOGY_AUTH_FAILED_EXCEPTIONS = ( + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, +) + +SYNOLOGY_CONNECTION_EXCEPTIONS = ( + SynologyDSMAPIErrorException, + SynologyDSMLoginFailedException, + SynologyDSMRequestException, +) diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py new file mode 100644 index 00000000000..e2f0f0741f4 --- /dev/null +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -0,0 +1,149 @@ +"""synology_dsm coordinators.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +import async_timeout +from synology_dsm.api.surveillance_station.camera import SynoCamera +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .common import SynoApi +from .const import ( + DEFAULT_SCAN_INTERVAL, + SIGNAL_CAMERA_SOURCE_CHANGED, + SYNOLOGY_CONNECTION_EXCEPTIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +class SynologyDSMUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator base class for synology_dsm.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + update_interval: timedelta, + ) -> None: + """Initialize synology_dsm DataUpdateCoordinator.""" + self.api = api + self.entry = entry + super().__init__( + hass, + _LOGGER, + name=f"{entry.title} {self.__class__.__name__}", + update_interval=update_interval, + ) + + +class SynologyDSMSwitchUpdateCoordinator(SynologyDSMUpdateCoordinator): + """DataUpdateCoordinator to gather data for a synology_dsm switch devices.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + ) -> None: + """Initialize DataUpdateCoordinator for switch devices.""" + super().__init__(hass, entry, api, timedelta(seconds=30)) + self.version: str | None = None + + async def async_setup(self) -> None: + """Set up the coordinator initial data.""" + info = await self.hass.async_add_executor_job( + self.api.dsm.surveillance_station.get_info + ) + self.version = info["data"]["CMSMinVersion"] + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch all data from api.""" + surveillance_station = self.api.surveillance_station + return { + "switches": { + "home_mode": await self.hass.async_add_executor_job( + surveillance_station.get_home_mode_status + ) + } + } + + +class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator): + """DataUpdateCoordinator to gather data for a synology_dsm central device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + ) -> None: + """Initialize DataUpdateCoordinator for central device.""" + super().__init__( + hass, + entry, + api, + timedelta( + minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ), + ) + + async def _async_update_data(self) -> None: + """Fetch all data from api.""" + try: + await self.api.async_update() + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + return None + + +class SynologyDSMCameraUpdateCoordinator(SynologyDSMUpdateCoordinator): + """DataUpdateCoordinator to gather data for a synology_dsm cameras.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + ) -> None: + """Initialize DataUpdateCoordinator for cameras.""" + super().__init__(hass, entry, api, timedelta(seconds=30)) + + async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]]: + """Fetch all camera data from api.""" + surveillance_station = self.api.surveillance_station + current_data: dict[str, SynoCamera] = { + camera.id: camera for camera in surveillance_station.get_all_cameras() + } + + try: + async with async_timeout.timeout(30): + await self.hass.async_add_executor_job(surveillance_station.update) + except SynologyDSMAPIErrorException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + new_data: dict[str, SynoCamera] = { + camera.id: camera for camera in surveillance_station.get_all_cameras() + } + + for cam_id, cam_data_new in new_data.items(): + if ( + (cam_data_current := current_data.get(cam_id)) is not None + and cam_data_current.live_view.rtsp != cam_data_new.live_view.rtsp + ): + async_dispatcher_send( + self.hass, + f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.entry.entry_id}_{cam_id}", + cam_data_new.live_view.rtsp, + ) + + return {"cameras": new_data} diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index 8709170a6f8..30af7f94282 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -1,28 +1,31 @@ """Diagnostics support for Synology DSM.""" from __future__ import annotations +from typing import Any + from synology_dsm.api.surveillance_station.camera import SynoCamera +from homeassistant.components.camera import diagnostics as camera_diagnostics from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import SynoApi -from .const import CONF_DEVICE_TOKEN, DOMAIN, SYNO_API, SYSTEM_LOADED +from .const import CONF_DEVICE_TOKEN, DOMAIN +from .models import SynologyDSMData TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN} async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry -) -> dict: +) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: dict = hass.data[DOMAIN][entry.unique_id] - syno_api: SynoApi = data[SYNO_API] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + syno_api = data.api dsm_info = syno_api.dsm.information - diag_data = { + diag_data: dict[str, Any] = { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "device_info": { "model": dsm_info.model, @@ -33,10 +36,10 @@ async def async_get_config_entry_diagnostics( }, "network": {"interfaces": {}}, "storage": {"disks": {}, "volumes": {}}, - "surveillance_station": {"cameras": {}}, + "surveillance_station": {"cameras": {}, "camera_diagnostics": {}}, "upgrade": {}, "utilisation": {}, - "is_system_loaded": data[SYSTEM_LOADED], + "is_system_loaded": True, "api_details": { "fetching_entities": syno_api._fetching_entities, # pylint: disable=protected-access }, @@ -79,6 +82,10 @@ async def async_get_config_entry_diagnostics( "model": camera.model, "resolution": camera.resolution, } + if camera_data := await camera_diagnostics.async_get_config_entry_diagnostics( + hass, entry + ): + diag_data["surveillance_station"]["camera_diagnostics"] = camera_data if syno_api.upgrade is not None: diag_data["upgrade"] = { diff --git a/homeassistant/components/synology_dsm/models.py b/homeassistant/components/synology_dsm/models.py new file mode 100644 index 00000000000..8c4341a2d37 --- /dev/null +++ b/homeassistant/components/synology_dsm/models.py @@ -0,0 +1,21 @@ +"""The synology_dsm integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .common import SynoApi +from .coordinator import ( + SynologyDSMCameraUpdateCoordinator, + SynologyDSMCentralUpdateCoordinator, + SynologyDSMSwitchUpdateCoordinator, +) + + +@dataclass +class SynologyDSMData: + """Data for the synology_dsm integration.""" + + api: SynoApi + coordinator_central: SynologyDSMCentralUpdateCoordinator + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 6015dc689b7..6a2a92b9fd5 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -31,12 +31,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow from . import SynoApi -from .const import CONF_VOLUMES, COORDINATOR_CENTRAL, DOMAIN, ENTITY_UNIT_LOAD, SYNO_API +from .const import CONF_VOLUMES, DOMAIN, ENTITY_UNIT_LOAD from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) +from .models import SynologyDSMData @dataclass @@ -279,10 +280,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS Sensor.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - coordinator = data[COORDINATOR_CENTRAL] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + api = data.api + coordinator = data.coordinator_central entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ SynoDSMUtilSensor(api, coordinator, description) diff --git a/homeassistant/components/synology_dsm/service.py b/homeassistant/components/synology_dsm/service.py index 130ad110b46..0cb2bf7d822 100644 --- a/homeassistant/components/synology_dsm/service.py +++ b/homeassistant/components/synology_dsm/service.py @@ -7,15 +7,8 @@ from synology_dsm.exceptions import SynologyDSMException from homeassistant.core import HomeAssistant, ServiceCall -from .common import SynoApi -from .const import ( - CONF_SERIAL, - DOMAIN, - SERVICE_REBOOT, - SERVICE_SHUTDOWN, - SERVICES, - SYNO_API, -) +from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES +from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) @@ -29,7 +22,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: dsm_devices = hass.data[DOMAIN] if serial: - dsm_device = dsm_devices.get(serial) + dsm_device: SynologyDSMData = hass.data[DOMAIN][serial] elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) serial = next(iter(dsm_devices)) @@ -45,7 +38,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: return if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if not (dsm_device := hass.data[DOMAIN].get(serial)): + if serial not in hass.data[DOMAIN]: LOGGER.error("DSM with specified serial %s not found", serial) return LOGGER.debug("%s DSM with serial %s", call.service, serial) @@ -53,7 +46,8 @@ async def async_setup_services(hass: HomeAssistant) -> None: "The %s service is deprecated and will be removed in future release. Please use the corresponding button entity", call.service, ) - dsm_api: SynoApi = dsm_device[SYNO_API] + dsm_device = hass.data[DOMAIN][serial] + dsm_api = dsm_device.api try: await getattr(dsm_api, f"async_{call.service}")() except SynologyDSMException as ex: diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index eb61b8334ca..26909ceddd9 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -15,8 +15,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi -from .const import COORDINATOR_SWITCHES, DOMAIN, SYNO_API +from .const import DOMAIN from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription +from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -42,30 +43,16 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS switch.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - - entities = [] - - if SynoSurveillanceStation.INFO_API_KEY in api.dsm.apis: - info = await hass.async_add_executor_job(api.dsm.surveillance_station.get_info) - version = info["data"]["CMSMinVersion"] - - # initial data fetch - coordinator: DataUpdateCoordinator = data[COORDINATOR_SWITCHES] - await coordinator.async_refresh() - entities.extend( - [ - SynoDSMSurveillanceHomeModeToggle( - api, version, coordinator, description - ) - for description in SURVEILLANCE_SWITCH - ] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + if coordinator := data.coordinator_switches: + assert coordinator.version is not None + async_add_entities( + SynoDSMSurveillanceHomeModeToggle( + data.api, coordinator.version, coordinator, description + ) + for description in SURVEILLANCE_SWITCH ) - async_add_entities(entities, True) - class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, SwitchEntity): """Representation a Synology Surveillance Station Home Mode toggle.""" diff --git a/homeassistant/components/synology_dsm/translations/bg.json b/homeassistant/components/synology_dsm/translations/bg.json index a3a107a36e2..dcd0a5ab730 100644 --- a/homeassistant/components/synology_dsm/translations/bg.json +++ b/homeassistant/components/synology_dsm/translations/bg.json @@ -6,6 +6,7 @@ "reconfigure_successful": "\u041f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "otp_failed": "\u0414\u0432\u0443\u0441\u0442\u0435\u043f\u0435\u043d\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u0441 \u043d\u043e\u0432 \u043a\u043e\u0434 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index 3917d9f800e..1ad2f52199c 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -28,7 +28,7 @@ "username": "Nom d'utilisateur", "verify_ssl": "V\u00e9rifier le certificat SSL" }, - "description": "Voulez-vous configurer {name} ({host})?" + "description": "Voulez-vous configurer {name} ({host})\u00a0?" }, "reauth_confirm": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/sv.json b/homeassistant/components/synology_dsm/translations/sv.json index 04814596518..012d092de41 100644 --- a/homeassistant/components/synology_dsm/translations/sv.json +++ b/homeassistant/components/synology_dsm/translations/sv.json @@ -12,6 +12,11 @@ }, "description": "Do vill du konfigurera {name} ({host})?" }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd", diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 48b3eeca2ed..d3f3cc56eac 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -13,9 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SynoApi -from .const import COORDINATOR_CENTRAL, DOMAIN, SYNO_API +from .const import DOMAIN from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription +from .models import SynologyDSMData @dataclass @@ -39,12 +39,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Synology DSM update entities.""" - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - coordinator = data[COORDINATOR_CENTRAL] - + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] async_add_entities( - SynoDSMUpdateEntity(api, coordinator, description) + SynoDSMUpdateEntity(data.api, data.coordinator_central, description) for description in UPDATE_ENTITIES ) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index c6edf5b61ea..1bee974a4c4 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -3,98 +3,105 @@ from __future__ import annotations import asyncio import logging -import shlex import async_timeout -from systembridge import Bridge -from systembridge.client import BridgeClient -from systembridge.exceptions import BridgeAuthenticationException -from systembridge.objects.command.response import CommandResponse -from systembridge.objects.keyboard.payload import KeyboardPayload +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, +) +from systembridgeconnector.version import SUPPORTED_VERSION, Version import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_COMMAND, CONF_HOST, CONF_PATH, CONF_PORT, + CONF_URL, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) -from homeassistant.helpers import ( - aiohttp_client, - config_validation as cv, - device_registry as dr, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .const import DOMAIN, MODULES from .coordinator import SystemBridgeDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] -CONF_ARGUMENTS = "arguments" CONF_BRIDGE = "bridge" CONF_KEY = "key" -CONF_MODIFIERS = "modifiers" CONF_TEXT = "text" -CONF_WAIT = "wait" -SERVICE_SEND_COMMAND = "send_command" -SERVICE_OPEN = "open" +SERVICE_OPEN_PATH = "open_path" +SERVICE_OPEN_URL = "open_url" SERVICE_SEND_KEYPRESS = "send_keypress" SERVICE_SEND_TEXT = "send_text" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up System Bridge from a config entry.""" - bridge = Bridge( - BridgeClient(aiohttp_client.async_get_clientsession(hass)), - f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", - entry.data[CONF_API_KEY], - ) + # Check version before initialising + version = Version( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) try: - async with async_timeout.timeout(30): - await bridge.async_get_information() - except BridgeAuthenticationException as exception: - raise ConfigEntryAuthFailed( - f"Authentication failed for {entry.title} ({entry.data[CONF_HOST]})" - ) from exception - except BRIDGE_CONNECTION_ERRORS as exception: + if not await version.check_supported(): + raise ConfigEntryNotReady( + f"You are not running a supported version of System Bridge. Please update to {SUPPORTED_VERSION} or higher." + ) + except AuthenticationException as exception: + _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) + raise ConfigEntryAuthFailed from exception + except (ConnectionClosedException, ConnectionErrorException) as exception: raise ConfigEntryNotReady( f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." ) from exception + except asyncio.TimeoutError as exception: + raise ConfigEntryNotReady( + f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + ) from exception + + coordinator = SystemBridgeDataUpdateCoordinator( + hass, + _LOGGER, + entry=entry, + ) + try: + async with async_timeout.timeout(30): + await coordinator.async_get_data(MODULES) + except AuthenticationException as exception: + _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) + raise ConfigEntryAuthFailed from exception + except (ConnectionClosedException, ConnectionErrorException) as exception: + raise ConfigEntryNotReady( + f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." + ) from exception + except asyncio.TimeoutError as exception: + raise ConfigEntryNotReady( + f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + ) from exception - coordinator = SystemBridgeDataUpdateCoordinator(hass, bridge, _LOGGER, entry=entry) await coordinator.async_config_entry_first_refresh() - # Wait for initial data try: - async with async_timeout.timeout(60): - while ( - coordinator.bridge.battery is None - or coordinator.bridge.cpu is None - or coordinator.bridge.display is None - or coordinator.bridge.filesystem is None - or coordinator.bridge.graphics is None - or coordinator.bridge.information is None - or coordinator.bridge.memory is None - or coordinator.bridge.network is None - or coordinator.bridge.os is None - or coordinator.bridge.processes is None - or coordinator.bridge.system is None - ): + # Wait for initial data + async with async_timeout.timeout(30): + while not coordinator.is_ready(): _LOGGER.debug( "Waiting for initial data from %s (%s)", entry.title, @@ -106,12 +113,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception + _LOGGER.debug( + "Initial coordinator data for %s (%s):\n%s", + entry.title, + entry.data[CONF_HOST], + coordinator.data.json(), + ) + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) - if hass.services.has_service(DOMAIN, SERVICE_SEND_COMMAND): + if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): return True def valid_device(device: str): @@ -129,104 +143,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise vol.Invalid from exception raise vol.Invalid(f"Device {device} does not exist") - async def handle_send_command(call: ServiceCall) -> None: - """Handle the send_command service call.""" + async def handle_open_path(call: ServiceCall) -> None: + """Handle the open path service call.""" + _LOGGER.info("Open: %s", call.data) coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.bridge + await coordinator.websocket_client.open_path(call.data[CONF_PATH]) - command = call.data[CONF_COMMAND] - arguments = shlex.split(call.data[CONF_ARGUMENTS]) - - _LOGGER.debug( - "Command payload: %s", - {CONF_COMMAND: command, CONF_ARGUMENTS: arguments, CONF_WAIT: False}, - ) - try: - response: CommandResponse = await bridge.async_send_command( - {CONF_COMMAND: command, CONF_ARGUMENTS: arguments, CONF_WAIT: False} - ) - if not response.success: - raise HomeAssistantError( - f"Error sending command. Response message was: {response.message}" - ) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending command") from exception - _LOGGER.debug("Sent command. Response message was: %s", response.message) - - async def handle_open(call: ServiceCall) -> None: - """Handle the open service call.""" + async def handle_open_url(call: ServiceCall) -> None: + """Handle the open url service call.""" + _LOGGER.info("Open: %s", call.data) coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.bridge - - path = call.data[CONF_PATH] - - _LOGGER.debug("Open payload: %s", {CONF_PATH: path}) - try: - await bridge.async_open({CONF_PATH: path}) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending") from exception - _LOGGER.debug("Sent open request") + await coordinator.websocket_client.open_url(call.data[CONF_URL]) async def handle_send_keypress(call: ServiceCall) -> None: """Handle the send_keypress service call.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.data - - keyboard_payload: KeyboardPayload = { - CONF_KEY: call.data[CONF_KEY], - CONF_MODIFIERS: shlex.split(call.data.get(CONF_MODIFIERS, "")), - } - - _LOGGER.debug("Keypress payload: %s", keyboard_payload) - try: - await bridge.async_send_keypress(keyboard_payload) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending") from exception - _LOGGER.debug("Sent keypress request") + await coordinator.websocket_client.keyboard_keypress(call.data[CONF_KEY]) async def handle_send_text(call: ServiceCall) -> None: """Handle the send_keypress service call.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.data - - keyboard_payload: KeyboardPayload = {CONF_TEXT: call.data[CONF_TEXT]} - - _LOGGER.debug("Text payload: %s", keyboard_payload) - try: - await bridge.async_send_keypress(keyboard_payload) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending") from exception - _LOGGER.debug("Sent text request") + await coordinator.websocket_client.keyboard_text(call.data[CONF_TEXT]) hass.services.async_register( DOMAIN, - SERVICE_SEND_COMMAND, - handle_send_command, + SERVICE_OPEN_PATH, + handle_open_path, schema=vol.Schema( { vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_ARGUMENTS, ""): cv.string, + vol.Required(CONF_PATH): cv.string, }, ), ) hass.services.async_register( DOMAIN, - SERVICE_OPEN, - handle_open, + SERVICE_OPEN_URL, + handle_open_url, schema=vol.Schema( { vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_PATH): cv.string, + vol.Required(CONF_URL): cv.string, }, ), ) @@ -239,7 +205,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: { vol.Required(CONF_BRIDGE): valid_device, vol.Required(CONF_KEY): cv.string, - vol.Optional(CONF_MODIFIERS): cv.string, }, ), ) @@ -271,15 +236,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] # Ensure disconnected and cleanup stop sub - await coordinator.bridge.async_close_websocket() + await coordinator.websocket_client.close() if coordinator.unsub: coordinator.unsub() del hass.data[DOMAIN][entry.entry_id] if not hass.data[DOMAIN]: - hass.services.async_remove(DOMAIN, SERVICE_SEND_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_OPEN) + hass.services.async_remove(DOMAIN, SERVICE_OPEN_PATH) + hass.services.async_remove(DOMAIN, SERVICE_OPEN_URL) + hass.services.async_remove(DOMAIN, SERVICE_SEND_KEYPRESS) + hass.services.async_remove(DOMAIN, SERVICE_SEND_TEXT) return unload_ok @@ -295,20 +262,21 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): def __init__( self, coordinator: SystemBridgeDataUpdateCoordinator, + api_port: int, key: str, name: str | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) - bridge: Bridge = coordinator.data - self._key = f"{bridge.information.host}_{key}" - self._name = f"{bridge.information.host} {name}" - self._configuration_url = bridge.get_configuration_url() - self._hostname = bridge.information.host - self._mac = bridge.information.mac - self._manufacturer = bridge.system.system.manufacturer - self._model = bridge.system.system.model - self._version = bridge.system.system.version + + self._hostname = coordinator.data.system.hostname + self._key = f"{self._hostname}_{key}" + self._name = f"{self._hostname} {name}" + self._configuration_url = ( + f"http://{self._hostname}:{api_port}/app/settings.html" + ) + self._mac_address = coordinator.data.system.mac_address + self._version = coordinator.data.system.version @property def unique_id(self) -> str: @@ -329,9 +297,7 @@ class SystemBridgeDeviceEntity(SystemBridgeEntity): """Return device information about this System Bridge instance.""" return DeviceInfo( configuration_url=self._configuration_url, - connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, - manufacturer=self._manufacturer, - model=self._model, + connections={(dr.CONNECTION_NETWORK_MAC, self._mac_address)}, name=self._hostname, sw_version=self._version, ) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index e592c8e82e4..9225aebf492 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -4,14 +4,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from systembridge import Bridge - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,7 +31,7 @@ BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] key="version_available", name="New Version Available", device_class=BinarySensorDeviceClass.UPDATE, - value=lambda bridge: bridge.information.updates.available, + value=lambda data: data.system.version_newer_available, ), ) @@ -41,7 +40,7 @@ BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, .. key="battery_is_charging", name="Battery Is Charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - value=lambda bridge: bridge.battery.isCharging, + value=lambda data: data.battery.is_charging, ), ) @@ -51,15 +50,24 @@ async def async_setup_entry( ) -> None: """Set up System Bridge binary sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - bridge: Bridge = coordinator.data entities = [] for description in BASE_BINARY_SENSOR_TYPES: - entities.append(SystemBridgeBinarySensor(coordinator, description)) + entities.append( + SystemBridgeBinarySensor(coordinator, description, entry.data[CONF_PORT]) + ) - if bridge.battery and bridge.battery.hasBattery: + if ( + coordinator.data.battery + and coordinator.data.battery.percentage + and coordinator.data.battery.percentage > -1 + ): for description in BATTERY_BINARY_SENSOR_TYPES: - entities.append(SystemBridgeBinarySensor(coordinator, description)) + entities.append( + SystemBridgeBinarySensor( + coordinator, description, entry.data[CONF_PORT] + ) + ) async_add_entities(entities) @@ -73,10 +81,12 @@ class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): self, coordinator: SystemBridgeDataUpdateCoordinator, description: SystemBridgeBinarySensorEntityDescription, + api_port: int, ) -> None: """Initialize.""" super().__init__( coordinator, + api_port, description.key, description.name, ) @@ -85,5 +95,4 @@ class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return the boolean state of the binary sensor.""" - bridge: Bridge = self.coordinator.data - return self.entity_description.value(bridge) + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 26ccf83c345..9d89cf83288 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -1,13 +1,19 @@ """Config flow for System Bridge integration.""" from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging from typing import Any import async_timeout -from systembridge import Bridge -from systembridge.client import BridgeClient -from systembridge.exceptions import BridgeAuthenticationException +from systembridgeconnector.const import EVENT_MODULE, EVENT_TYPE, TYPE_DATA_UPDATE +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, +) +from systembridgeconnector.websocket_client import WebSocketClient import voluptuous as vol from homeassistant import config_entries, exceptions @@ -15,9 +21,10 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,39 +38,84 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: +async def validate_input( + hass: HomeAssistant, + data: dict[str, Any], +) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - bridge = Bridge( - BridgeClient(aiohttp_client.async_get_clientsession(hass)), - f"http://{data[CONF_HOST]}:{data[CONF_PORT]}", + host = data[CONF_HOST] + + websocket_client = WebSocketClient( + host, + data[CONF_PORT], data[CONF_API_KEY], ) - - hostname = data[CONF_HOST] try: async with async_timeout.timeout(30): - await bridge.async_get_information() - if ( - bridge.information is not None - and bridge.information.host is not None - and bridge.information.uuid is not None - ): - hostname = bridge.information.host - uuid = bridge.information.uuid - except BridgeAuthenticationException as exception: - _LOGGER.info(exception) + await websocket_client.connect(session=async_get_clientsession(hass)) + await websocket_client.get_data(["system"]) + while True: + message = await websocket_client.receive_message() + _LOGGER.debug("Message: %s", message) + if ( + message[EVENT_TYPE] == TYPE_DATA_UPDATE + and message[EVENT_MODULE] == "system" + ): + break + except AuthenticationException as exception: + _LOGGER.warning( + "Authentication error when connecting to %s: %s", data[CONF_HOST], exception + ) raise InvalidAuth from exception - except BRIDGE_CONNECTION_ERRORS as exception: - _LOGGER.info(exception) + except ( + ConnectionClosedException, + ConnectionErrorException, + ) as exception: + _LOGGER.warning( + "Connection error when connecting to %s: %s", data[CONF_HOST], exception + ) + raise CannotConnect from exception + except asyncio.TimeoutError as exception: + _LOGGER.warning("Timed out connecting to %s: %s", data[CONF_HOST], exception) raise CannotConnect from exception - return {"hostname": hostname, "uuid": uuid} + _LOGGER.debug("%s Message: %s", TYPE_DATA_UPDATE, message) + + if "uuid" not in message["data"]: + error = "No UUID in result!" + raise CannotConnect(error) + + return {"hostname": host, "uuid": message["data"]["uuid"]} -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +async def _async_get_info( + hass: HomeAssistant, + user_input: dict[str, Any], +) -> tuple[dict[str, str], dict[str, str] | None]: + errors = {} + + try: + info = await validate_input(hass, user_input) + 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: + return errors, info + + return errors, None + + +class ConfigFlow( + config_entries.ConfigFlow, + domain=DOMAIN, +): """Handle a config flow for System Bridge.""" VERSION = 1 @@ -74,25 +126,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._input: dict[str, Any] = {} self._reauth = False - async def _async_get_info( - self, user_input: dict[str, Any] - ) -> tuple[dict[str, str], dict[str, str] | None]: - errors = {} - - try: - info = await validate_input(self.hass, user_input) - 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: - return errors, info - - return errors, None - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -102,7 +135,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - errors, info = await self._async_get_info(user_input) + errors, info = await _async_get_info(self.hass, user_input) if not errors and info is not None: # Check if already configured await self.async_set_unique_id(info["uuid"], raise_on_progress=False) @@ -122,7 +155,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: user_input = {**self._input, **user_input} - errors, info = await self._async_get_info(user_input) + errors, info = await _async_get_info(self.hass, user_input) if not errors and info is not None: # Check if already configured existing_entry = await self.async_set_unique_id(info["uuid"]) @@ -170,7 +203,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_authenticate() - async def async_step_reauth(self, entry_data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._name = entry_data[CONF_HOST] self._input = { diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index f2e83ceb186..c71ee86c920 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -1,20 +1,13 @@ """Constants for the System Bridge integration.""" -import asyncio - -from aiohttp.client_exceptions import ( - ClientConnectionError, - ClientConnectorError, - ClientResponseError, -) -from systembridge.exceptions import BridgeException DOMAIN = "system_bridge" -BRIDGE_CONNECTION_ERRORS = ( - asyncio.TimeoutError, - BridgeException, - ClientConnectionError, - ClientConnectorError, - ClientResponseError, - OSError, -) +MODULES = [ + "battery", + "cpu", + "disk", + "display", + "gpu", + "memory", + "system", +] diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 896309f2593..6088967aa33 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -6,140 +6,196 @@ from collections.abc import Callable from datetime import timedelta import logging -from systembridge import Bridge -from systembridge.exceptions import ( - BridgeAuthenticationException, - BridgeConnectionClosedException, - BridgeException, +import async_timeout +from pydantic import BaseModel # pylint: disable=no-name-in-module +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, ) -from systembridge.objects.events import Event +from systembridgeconnector.models.battery import Battery +from systembridgeconnector.models.cpu import Cpu +from systembridgeconnector.models.disk import Disk +from systembridgeconnector.models.display import Display +from systembridgeconnector.models.gpu import Gpu +from systembridgeconnector.models.memory import Memory +from systembridgeconnector.models.system import System +from systembridgeconnector.websocket_client import WebSocketClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .const import DOMAIN, MODULES -class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[Bridge]): +class SystemBridgeCoordinatorData(BaseModel): + """System Bridge Coordianator Data.""" + + battery: Battery = None + cpu: Cpu = None + disk: Disk = None + display: Display = None + gpu: Gpu = None + memory: Memory = None + system: System = None + + +class SystemBridgeDataUpdateCoordinator( + DataUpdateCoordinator[SystemBridgeCoordinatorData] +): """Class to manage fetching System Bridge data from single endpoint.""" def __init__( self, hass: HomeAssistant, - bridge: Bridge, LOGGER: logging.Logger, *, entry: ConfigEntry, ) -> None: """Initialize global System Bridge data updater.""" - self.bridge = bridge self.title = entry.title - self.host = entry.data[CONF_HOST] self.unsub: Callable | None = None + self.systembridge_data = SystemBridgeCoordinatorData() + self.websocket_client = WebSocketClient( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_API_KEY], + ) + super().__init__( hass, LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() + def is_ready(self) -> bool: + """Return if the data is ready.""" + if self.data is None: + return False + for module in MODULES: + if getattr(self.data, module) is None: + self.logger.debug("%s - Module %s is None", self.title, module) + return False + return True - async def async_handle_event(self, event: Event): - """Handle System Bridge events from the WebSocket.""" - # No need to update anything, as everything is updated in the caller - self.logger.debug( - "New event from %s (%s): %s", self.title, self.host, event.name - ) - self.async_set_updated_data(self.bridge) + async def async_get_data( + self, + modules: list[str], + ) -> None: + """Get data from WebSocket.""" + if not self.websocket_client.connected: + await self._setup_websocket() - async def _listen_for_events(self) -> None: + await self.websocket_client.get_data(modules) + + async def async_handle_module( + self, + module_name: str, + module, + ) -> None: + """Handle data from the WebSocket client.""" + self.logger.debug("Set new data for: %s", module_name) + setattr(self.systembridge_data, module_name, module) + self.async_set_updated_data(self.systembridge_data) + + async def _listen_for_data(self) -> None: """Listen for events from the WebSocket.""" + try: - await self.bridge.async_send_event( - "get-data", - [ - {"service": "battery", "method": "findAll", "observe": True}, - {"service": "cpu", "method": "findAll", "observe": True}, - {"service": "display", "method": "findAll", "observe": True}, - {"service": "filesystem", "method": "findSizes", "observe": True}, - {"service": "graphics", "method": "findAll", "observe": True}, - {"service": "memory", "method": "findAll", "observe": True}, - {"service": "network", "method": "findAll", "observe": True}, - {"service": "os", "method": "findAll", "observe": False}, - { - "service": "processes", - "method": "findCurrentLoad", - "observe": True, - }, - {"service": "system", "method": "findAll", "observe": False}, - ], - ) - await self.bridge.listen_for_events(callback=self.async_handle_event) - except BridgeConnectionClosedException as exception: + await self.websocket_client.register_data_listener(MODULES) + await self.websocket_client.listen(callback=self.async_handle_module) + except AuthenticationException as exception: self.last_update_success = False + self.logger.error("Authentication failed for %s: %s", self.title, exception) + if self.unsub: + self.unsub() + self.unsub = None + self.last_update_success = False + self.async_update_listeners() + except (ConnectionClosedException, ConnectionResetError) as exception: self.logger.info( - "Websocket Connection Closed for %s (%s). Will retry: %s", + "Websocket connection closed for %s. Will retry: %s", self.title, - self.host, exception, ) - except BridgeException as exception: + if self.unsub: + self.unsub() + self.unsub = None self.last_update_success = False - self.update_listeners() + self.async_update_listeners() + except ConnectionErrorException as exception: self.logger.warning( - "Exception occurred for %s (%s). Will retry: %s", + "Connection error occurred for %s. Will retry: %s", self.title, - self.host, exception, ) + if self.unsub: + self.unsub() + self.unsub = None + self.last_update_success = False + self.async_update_listeners() async def _setup_websocket(self) -> None: """Use WebSocket for updates.""" - try: - self.logger.debug( - "Connecting to ws://%s:%s", - self.host, - self.bridge.information.websocketPort, - ) - await self.bridge.async_connect_websocket( - self.host, self.bridge.information.websocketPort - ) - except BridgeAuthenticationException as exception: + async with async_timeout.timeout(20): + await self.websocket_client.connect( + session=async_get_clientsession(self.hass), + ) + except AuthenticationException as exception: + self.last_update_success = False + self.logger.error("Authentication failed for %s: %s", self.title, exception) if self.unsub: self.unsub() self.unsub = None - raise ConfigEntryAuthFailed() from exception - except (*BRIDGE_CONNECTION_ERRORS, ConnectionRefusedError) as exception: - if self.unsub: - self.unsub() - self.unsub = None - raise UpdateFailed( - f"Could not connect to {self.title} ({self.host})." - ) from exception - asyncio.create_task(self._listen_for_events()) + self.last_update_success = False + self.async_update_listeners() + except ConnectionErrorException as exception: + self.logger.warning( + "Connection error occurred for %s. Will retry: %s", + self.title, + exception, + ) + self.last_update_success = False + self.async_update_listeners() + except asyncio.TimeoutError as exception: + self.logger.warning( + "Timed out waiting for %s. Will retry: %s", + self.title, + exception, + ) + self.last_update_success = False + self.async_update_listeners() + + self.hass.async_create_task(self._listen_for_data()) + self.last_update_success = True + self.async_update_listeners() async def close_websocket(_) -> None: """Close WebSocket connection.""" - await self.bridge.async_close_websocket() + await self.websocket_client.close() # Clean disconnect WebSocket on Home Assistant shutdown self.unsub = self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, close_websocket ) - async def _async_update_data(self) -> Bridge: + async def _async_update_data(self) -> SystemBridgeCoordinatorData: """Update System Bridge data from WebSocket.""" self.logger.debug( "_async_update_data - WebSocket Connected: %s", - self.bridge.websocket_connected, + self.websocket_client.connected, ) - if not self.bridge.websocket_connected: + if not self.websocket_client.connected: await self._setup_websocket() - return self.bridge + self.logger.debug("_async_update_data done") + + return self.systembridge_data diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 8fba9dd30cf..087613413d8 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,11 +3,11 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridge==2.3.1"], + "requirements": ["systembridgeconnector==3.1.5"], "codeowners": ["@timmo001"], - "zeroconf": ["_system-bridge._udp.local."], + "zeroconf": ["_system-bridge._tcp.local."], "after_dependencies": ["zeroconf"], "quality_scale": "silver", "iot_class": "local_push", - "loggers": ["systembridge"] + "loggers": ["systembridgeconnector"] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index e66749820a7..bdfe5047e56 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -3,11 +3,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Final, cast -from systembridge import Bridge - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -16,6 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_PORT, DATA_GIGABYTES, ELECTRIC_POTENTIAL_VOLT, FREQUENCY_GIGAHERTZ, @@ -28,10 +27,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow from . import SystemBridgeDeviceEntity from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator ATTR_AVAILABLE: Final = "available" ATTR_FILESYSTEM: Final = "filesystem" @@ -41,6 +41,7 @@ ATTR_TYPE: Final = "type" ATTR_USED: Final = "used" PIXELS: Final = "px" +RPM: Final = "RPM" @dataclass @@ -50,13 +51,88 @@ class SystemBridgeSensorEntityDescription(SensorEntityDescription): value: Callable = round +def battery_time_remaining(data: SystemBridgeCoordinatorData) -> datetime | None: + """Return the battery time remaining.""" + if data.battery.sensors_secsleft is not None: + return utcnow() + timedelta(seconds=data.battery.sensors_secsleft) + return None + + +def cpu_speed(data: SystemBridgeCoordinatorData) -> float | None: + """Return the CPU speed.""" + if data.cpu.frequency_current is not None: + return round(data.cpu.frequency_current / 1000, 2) + return None + + +def gpu_core_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the GPU core clock speed.""" + if getattr(data.gpu, f"{key}_core_clock") is not None: + return round(getattr(data.gpu, f"{key}_core_clock")) + return None + + +def gpu_memory_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the GPU memory clock speed.""" + if getattr(data.gpu, f"{key}_memory_clock") is not None: + return round(getattr(data.gpu, f"{key}_memory_clock")) + return None + + +def gpu_memory_free(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the free GPU memory.""" + if getattr(data.gpu, f"{key}_memory_free") is not None: + return round(getattr(data.gpu, f"{key}_memory_free") / 10**3, 2) + return None + + +def gpu_memory_used(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the used GPU memory.""" + if getattr(data.gpu, f"{key}_memory_used") is not None: + return round(getattr(data.gpu, f"{key}_memory_used") / 10**3, 2) + return None + + +def gpu_memory_used_percentage( + data: SystemBridgeCoordinatorData, key: str +) -> float | None: + """Return the used GPU memory percentage.""" + if ( + getattr(data.gpu, f"{key}_memory_used") is not None + and getattr(data.gpu, f"{key}_memory_total") is not None + ): + return round( + getattr(data.gpu, f"{key}_memory_used") + / getattr(data.gpu, f"{key}_memory_total") + * 100, + 2, + ) + return None + + +def memory_free(data: SystemBridgeCoordinatorData) -> float | None: + """Return the free memory.""" + if data.memory.virtual_free is not None: + return round(data.memory.virtual_free / 1000**3, 2) + return None + + +def memory_used(data: SystemBridgeCoordinatorData) -> float | None: + """Return the used memory.""" + if data.memory.virtual_used is not None: + return round(data.memory.virtual_used / 1000**3, 2) + return None + + BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( - key="bios_version", - name="BIOS Version", - entity_registry_enabled_default=False, - icon="mdi:chip", - value=lambda bridge: bridge.system.bios.version, + key="boot_time", + name="Boot Time", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:av-timer", + value=lambda data: datetime.fromtimestamp( + data.system.boot_time, tz=timezone.utc + ), ), SystemBridgeSensorEntityDescription( key="cpu_speed", @@ -64,7 +140,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=FREQUENCY_GIGAHERTZ, icon="mdi:speedometer", - value=lambda bridge: bridge.cpu.currentSpeed.avg, + value=cpu_speed, ), SystemBridgeSensorEntityDescription( key="cpu_temperature", @@ -73,7 +149,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, - value=lambda bridge: bridge.cpu.temperature.main, + value=lambda data: data.cpu.temperature, ), SystemBridgeSensorEntityDescription( key="cpu_voltage", @@ -82,21 +158,14 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - value=lambda bridge: bridge.cpu.cpu.voltage, - ), - SystemBridgeSensorEntityDescription( - key="displays_connected", - name="Displays Connected", - state_class=SensorStateClass.MEASUREMENT, - icon="mdi:monitor", - value=lambda bridge: len(bridge.display.displays), + value=lambda data: data.cpu.voltage, ), SystemBridgeSensorEntityDescription( key="kernel", name="Kernel", state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", - value=lambda bridge: bridge.os.kernel, + value=lambda data: data.system.platform, ), SystemBridgeSensorEntityDescription( key="memory_free", @@ -104,7 +173,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:memory", - value=lambda bridge: round(bridge.memory.free / 1000**3, 2), + value=memory_free, ), SystemBridgeSensorEntityDescription( key="memory_used_percentage", @@ -112,7 +181,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", - value=lambda bridge: round((bridge.memory.used / bridge.memory.total) * 100, 2), + value=lambda data: data.memory.virtual_percent, ), SystemBridgeSensorEntityDescription( key="memory_used", @@ -121,14 +190,14 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:memory", - value=lambda bridge: round(bridge.memory.used / 1000**3, 2), + value=memory_used, ), SystemBridgeSensorEntityDescription( key="os", name="Operating System", state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", - value=lambda bridge: f"{bridge.os.distro} {bridge.os.release}", + value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), SystemBridgeSensorEntityDescription( key="processes_load", @@ -136,46 +205,19 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoad, 2), - ), - SystemBridgeSensorEntityDescription( - key="processes_load_idle", - name="Idle Load", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoadIdle, 2), - ), - SystemBridgeSensorEntityDescription( - key="processes_load_system", - name="System Load", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoadSystem, 2), - ), - SystemBridgeSensorEntityDescription( - key="processes_load_user", - name="User Load", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoadUser, 2), + value=lambda data: data.cpu.usage, ), SystemBridgeSensorEntityDescription( key="version", name="Version", icon="mdi:counter", - value=lambda bridge: bridge.information.version, + value=lambda data: data.system.version, ), SystemBridgeSensorEntityDescription( key="version_latest", name="Latest Version", icon="mdi:counter", - value=lambda bridge: bridge.information.updates.version.new, + value=lambda data: data.system.version_latest, ), ) @@ -186,238 +228,270 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value=lambda bridge: bridge.battery.percent, + value=lambda data: data.battery.percentage, ), SystemBridgeSensorEntityDescription( key="battery_time_remaining", name="Battery Time Remaining", device_class=SensorDeviceClass.TIMESTAMP, state_class=SensorStateClass.MEASUREMENT, - value=lambda bridge: str( - datetime.now() + timedelta(minutes=bridge.battery.timeRemaining) - ), + value=battery_time_remaining, ), ) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up System Bridge sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [] for description in BASE_SENSOR_TYPES: - entities.append(SystemBridgeSensor(coordinator, description)) + entities.append( + SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) + ) - for key, _ in coordinator.data.filesystem.fsSize.items(): - uid = key.replace(":", "") + for partition in coordinator.data.disk.partitions: entities.append( SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"filesystem_{uid}", - name=f"{key} Space Used", + key=f"filesystem_{partition.replace(':', '')}", + name=f"{partition} Space Used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", - value=lambda bridge, i=key: round( - bridge.filesystem.fsSize[i]["use"], 2 + value=lambda data, p=partition: getattr( + data.disk, f"usage_{p}_percent" ), ), + entry.data[CONF_PORT], ) ) - if coordinator.data.battery.hasBattery: + if ( + coordinator.data.battery + and coordinator.data.battery.percentage + and coordinator.data.battery.percentage > -1 + ): for description in BATTERY_SENSOR_TYPES: - entities.append(SystemBridgeSensor(coordinator, description)) + entities.append( + SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) + ) - for index, _ in enumerate(coordinator.data.display.displays): - name = index + 1 + displays = [] + for display in coordinator.data.display.displays: + displays.append( + { + "key": display, + "name": getattr(coordinator.data.display, f"{display}_name").replace( + "Display ", "" + ), + }, + ) + display_count = len(displays) + + entities.append( + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key="displays_connected", + name="Displays Connected", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:monitor", + value=lambda _, count=display_count: count, + ), + entry.data[CONF_PORT], + ) + ) + + for _, display in enumerate(displays): entities = [ *entities, SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"display_{name}_resolution_x", - name=f"Display {name} Resolution X", + key=f"display_{display['name']}_resolution_x", + name=f"Display {display['name']} Resolution X", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", - value=lambda bridge, i=index: bridge.display.displays[ - i - ].resolutionX, + value=lambda data, k=display["key"]: getattr( + data.display, f"{k}_resolution_horizontal" + ), ), + entry.data[CONF_PORT], ), SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"display_{name}_resolution_y", - name=f"Display {name} Resolution Y", + key=f"display_{display['name']}_resolution_y", + name=f"Display {display['name']} Resolution Y", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", - value=lambda bridge, i=index: bridge.display.displays[ - i - ].resolutionY, + value=lambda data, k=display["key"]: getattr( + data.display, f"{k}_resolution_vertical" + ), ), + entry.data[CONF_PORT], ), SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"display_{name}_refresh_rate", - name=f"Display {name} Refresh Rate", + key=f"display_{display['name']}_refresh_rate", + name=f"Display {display['name']} Refresh Rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:monitor", - value=lambda bridge, i=index: bridge.display.displays[ - i - ].currentRefreshRate, + value=lambda data, k=display["key"]: getattr( + data.display, f"{k}_refresh_rate" + ), ), + entry.data[CONF_PORT], ), ] - for index, _ in enumerate(coordinator.data.graphics.controllers): - if coordinator.data.graphics.controllers[index].name is not None: - # Remove vendor from name - name = ( - coordinator.data.graphics.controllers[index] - .name.replace(coordinator.data.graphics.controllers[index].vendor, "") - .strip() - ) - entities = [ - *entities, - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_core_clock_speed", - name=f"{name} Clock Speed", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=FREQUENCY_MEGAHERTZ, - icon="mdi:speedometer", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].clockCore, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_clock_speed", - name=f"{name} Memory Clock Speed", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=FREQUENCY_MEGAHERTZ, - icon="mdi:speedometer", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].clockMemory, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_free", - name=f"{name} Memory Free", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=DATA_GIGABYTES, - icon="mdi:memory", - value=lambda bridge, i=index: round( - bridge.graphics.controllers[i].memoryFree / 10**3, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_used_percentage", - name=f"{name} Memory Used %", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", - value=lambda bridge, i=index: round( - ( - bridge.graphics.controllers[i].memoryUsed - / bridge.graphics.controllers[i].memoryTotal - ) - * 100, - 2, - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_used", - name=f"{name} Memory Used", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=DATA_GIGABYTES, - icon="mdi:memory", - value=lambda bridge, i=index: round( - bridge.graphics.controllers[i].memoryUsed / 10**3, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_fan_speed", - name=f"{name} Fan Speed", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:fan", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].fanSpeed, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_power_usage", - name=f"{name} Power Usage", - entity_registry_enabled_default=False, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=POWER_WATT, - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].powerDraw, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_temperature", - name=f"{name} Temperature", - entity_registry_enabled_default=False, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=TEMP_CELSIUS, - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].temperatureGpu, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_usage_percentage", - name=f"{name} Usage %", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].utilizationGpu, - ), - ), - ] + gpus = [] + for gpu in coordinator.data.gpu.gpus: + gpus.append( + { + "key": gpu, + "name": getattr(coordinator.data.gpu, f"{gpu}_name"), + }, + ) - for index, _ in enumerate(coordinator.data.processes.load.cpus): + for index, gpu in enumerate(gpus): + entities = [ + *entities, + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_core_clock_speed", + name=f"{gpu['name']} Clock Speed", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda data, k=gpu["key"]: gpu_core_clock_speed(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_clock_speed", + name=f"{gpu['name']} Memory Clock Speed", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda data, k=gpu["key"]: gpu_memory_clock_speed(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_free", + name=f"{gpu['name']} Memory Free", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda data, k=gpu["key"]: gpu_memory_free(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used_percentage", + name=f"{gpu['name']} Memory Used %", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + value=lambda data, k=gpu["key"]: gpu_memory_used_percentage( + data, k + ), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used", + name=f"{gpu['name']} Memory Used", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda data, k=gpu["key"]: gpu_memory_used(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_fan_speed", + name=f"{gpu['name']} Fan Speed", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=RPM, + icon="mdi:fan", + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_fan_speed" + ), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_power_usage", + name=f"{gpu['name']} Power Usage", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + value=lambda data, k=gpu["key"]: getattr(data.gpu, f"{k}_power"), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_temperature", + name=f"{gpu['name']} Temperature", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_temperature" + ), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_usage_percentage", + name=f"{gpu['name']} Usage %", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_core_load" + ), + ), + entry.data[CONF_PORT], + ), + ] + + for index in range(coordinator.data.cpu.count): entities = [ *entities, SystemBridgeSensor( @@ -429,52 +503,9 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].load, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"processes_load_cpu_{index}_idle", - name=f"Idle Load CPU {index}", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].loadIdle, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"processes_load_cpu_{index}_system", - name=f"System Load CPU {index}", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].loadSystem, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"processes_load_cpu_{index}_user", - name=f"User Load CPU {index}", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].loadUser, 2 - ), + value=lambda data, k=index: getattr(data.cpu, f"usage_{k}"), ), + entry.data[CONF_PORT], ), ] @@ -490,10 +521,12 @@ class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): self, coordinator: SystemBridgeDataUpdateCoordinator, description: SystemBridgeSensorEntityDescription, + api_port: int, ) -> None: """Initialize.""" super().__init__( coordinator, + api_port, description.key, description.name, ) @@ -502,8 +535,7 @@ class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state.""" - bridge: Bridge = self.coordinator.data try: - return cast(StateType, self.entity_description.value(bridge)) + return cast(StateType, self.entity_description.value(self.coordinator.data)) except TypeError: return None diff --git a/homeassistant/components/system_bridge/services.yaml b/homeassistant/components/system_bridge/services.yaml index aff0094501e..d33235ffba4 100644 --- a/homeassistant/components/system_bridge/services.yaml +++ b/homeassistant/components/system_bridge/services.yaml @@ -1,32 +1,6 @@ -send_command: - name: Send Command - description: Sends a command to the server to run. - fields: - bridge: - name: Bridge - description: The server to send the command to. - required: true - selector: - device: - integration: system_bridge - command: - name: Command - description: Command to send to the server. - required: true - example: "echo" - selector: - text: - arguments: - name: Arguments - description: Arguments to send to the server. - required: false - default: "" - example: "hello" - selector: - text: -open: - name: Open Path/URL - description: Open a URL or file on the server using the default application. +open_path: + name: Open Path + description: Open a file on the server using the default application. fields: bridge: name: Bridge @@ -36,8 +10,26 @@ open: device: integration: system_bridge path: - name: Path/URL - description: Path/URL to open. + name: Path + description: Path to open. + required: true + example: "C:\\test\\image.png" + selector: + text: +open_url: + name: Open URL + description: Open a URL on the server using the default application. + fields: + bridge: + name: Bridge + description: The server to talk to. + required: true + selector: + device: + integration: system_bridge + url: + name: URL + description: URL to open. required: true example: "https://www.home-assistant.io" selector: @@ -60,16 +52,6 @@ send_keypress: example: "audio_play" selector: text: - modifiers: - name: Modifiers - description: "List of modifier(s). Accepts alt, command/win, control, and shift." - required: false - default: "" - example: - - "control" - - "shift" - selector: - text: send_text: name: Send Keyboard Text description: Sends text for the server to type. diff --git a/homeassistant/components/system_bridge/translations/sv.json b/homeassistant/components/system_bridge/translations/sv.json new file mode 100644 index 00000000000..5fa635734cb --- /dev/null +++ b/homeassistant/components/system_bridge/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index a03b370d0a3..ec195573203 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Tado integration.""" +from __future__ import annotations + import logging from PyTado.interface import Tado @@ -112,7 +114,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/tailscale/config_flow.py b/homeassistant/components/tailscale/config_flow.py index f1180db5254..5f28c566801 100644 --- a/homeassistant/components/tailscale/config_flow.py +++ b/homeassistant/components/tailscale/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Tailscale integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from tailscale import Tailscale, TailscaleAuthenticationError, TailscaleError @@ -81,7 +82,7 @@ class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Tailscale.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/tailscale/translations/sv.json b/homeassistant/components/tailscale/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/tailscale/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index e63add83fad..b7a15e82ea8 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -187,6 +187,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): 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, diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 345b034b027..e3d273825a5 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -133,7 +133,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not user_input: return self.async_show_form( step_id="select_station", - description_placeholders={"stations_count": len(self._stations)}, + description_placeholders={"stations_count": str(len(self._stations))}, data_schema=vol.Schema( {vol.Required(CONF_STATIONS): cv.multi_select(self._stations)} ), @@ -144,6 +144,28 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): options={CONF_SHOW_ON_MAP: True}, ) + 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: + """Perform reauth confirm upon an API authentication error.""" + if not user_input: + return self._show_form_reauth() + + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + user_input = {**entry.data, **user_input} + data = await async_get_nearby_stations(self.hass, user_input) + if not data.get("ok"): + return self._show_form_reauth(user_input, {CONF_API_KEY: "invalid_auth"}) + + self.hass.config_entries.async_update_entry(entry, data=user_input) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + def _show_form_user( self, user_input: dict[str, Any] | None = None, @@ -190,6 +212,25 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + def _show_form_reauth( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, Any] | None = None, + ) -> FlowResult: + if user_input is None: + user_input = {} + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") + ): cv.string, + } + ), + errors=errors, + ) + def _create_entry( self, data: dict[str, Any], options: dict[str, Any] ) -> FlowResult: diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 5e0c367c192..e0b9b3d53e8 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -11,6 +11,11 @@ "radius": "Search radius" } }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, "select_station": { "title": "Select stations to add", "description": "found {stations_count} stations in radius", @@ -20,7 +25,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", diff --git a/homeassistant/components/tankerkoenig/translations/bg.json b/homeassistant/components/tankerkoenig/translations/bg.json index 700c4f3000b..8631e4a1daa 100644 --- a/homeassistant/components/tankerkoenig/translations/bg.json +++ b/homeassistant/components/tankerkoenig/translations/bg.json @@ -1,12 +1,18 @@ { "config": { "abort": { - "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + }, "user": { "data": { "api_key": "API \u043a\u043b\u044e\u0447", diff --git a/homeassistant/components/tankerkoenig/translations/ca.json b/homeassistant/components/tankerkoenig/translations/ca.json index 676bb1ccb55..46d523276e7 100644 --- a/homeassistant/components/tankerkoenig/translations/ca.json +++ b/homeassistant/components/tankerkoenig/translations/ca.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada" + "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "no_stations": "No s'ha pogut trobar cap estaci\u00f3 a l'abast." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Clau API" + } + }, "select_station": { "data": { "stations": "Estacions" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Interval d'actualitzaci\u00f3", - "show_on_map": "Mostra les estacions al mapa" + "show_on_map": "Mostra les estacions al mapa", + "stations": "Estacions" }, "title": "Opcions de Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/de.json b/homeassistant/components/tankerkoenig/translations/de.json index 3c2a5f1ec72..f0ad25857a5 100644 --- a/homeassistant/components/tankerkoenig/translations/de.json +++ b/homeassistant/components/tankerkoenig/translations/de.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Standort ist bereits konfiguriert" + "already_configured": "Standort ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung", "no_stations": "Konnte keine Station in Reichweite finden." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-Schl\u00fcssel" + } + }, "select_station": { "data": { "stations": "Stationen" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Update-Intervall", - "show_on_map": "Stationen auf der Karte anzeigen" + "show_on_map": "Stationen auf der Karte anzeigen", + "stations": "Stationen" }, "title": "Tankerkoenig Optionen" } diff --git a/homeassistant/components/tankerkoenig/translations/el.json b/homeassistant/components/tankerkoenig/translations/el.json index 7f814b9760c..82dc5b9019b 100644 --- a/homeassistant/components/tankerkoenig/translations/el.json +++ b/homeassistant/components/tankerkoenig/translations/el.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "\u0397 \u03c4\u03bf\u03c0\u03bf\u03b8\u03b5\u03c3\u03af\u03b1 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + "already_configured": "\u0397 \u03c4\u03bf\u03c0\u03bf\u03b8\u03b5\u03c3\u03af\u03b1 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "reauth_successful": "\u039f \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" }, "error": { "invalid_auth": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", "no_stations": "\u0394\u03b5\u03bd \u03ae\u03c4\u03b1\u03bd \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03b5\u03cd\u03c1\u03b5\u03c3\u03b7 \u03c3\u03c4\u03b1\u03b8\u03bc\u03bf\u03cd \u03b5\u03bd\u03c4\u03cc\u03c2 \u03b5\u03bc\u03b2\u03ad\u03bb\u03b5\u03b9\u03b1\u03c2." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + } + }, "select_station": { "data": { "stations": "\u03a3\u03c4\u03b1\u03b8\u03bc\u03bf\u03af" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "\u0394\u03b9\u03ac\u03c3\u03c4\u03b7\u03bc\u03b1 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7\u03c2", - "show_on_map": "\u0395\u03bc\u03c6\u03ac\u03bd\u03b9\u03c3\u03b7 \u03c3\u03c4\u03b1\u03b8\u03bc\u03ce\u03bd \u03c3\u03c4\u03bf \u03c7\u03ac\u03c1\u03c4\u03b7" + "show_on_map": "\u0395\u03bc\u03c6\u03ac\u03bd\u03b9\u03c3\u03b7 \u03c3\u03c4\u03b1\u03b8\u03bc\u03ce\u03bd \u03c3\u03c4\u03bf \u03c7\u03ac\u03c1\u03c4\u03b7", + "stations": "\u03a3\u03c4\u03b1\u03b8\u03bc\u03bf\u03af" }, "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2 Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/en.json b/homeassistant/components/tankerkoenig/translations/en.json index 83cc36fd4c8..64c585f838b 100644 --- a/homeassistant/components/tankerkoenig/translations/en.json +++ b/homeassistant/components/tankerkoenig/translations/en.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Location is already configured" + "already_configured": "Location is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_auth": "Invalid authentication", "no_stations": "Could not find any station in range." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + } + }, "select_station": { "data": { "stations": "Stations" @@ -31,6 +37,7 @@ "step": { "init": { "data": { + "scan_interval": "Update Interval", "show_on_map": "Show stations on map", "stations": "Stations" }, diff --git a/homeassistant/components/tankerkoenig/translations/es.json b/homeassistant/components/tankerkoenig/translations/es.json index ec97b5886d3..bda6c43ce6e 100644 --- a/homeassistant/components/tankerkoenig/translations/es.json +++ b/homeassistant/components/tankerkoenig/translations/es.json @@ -1,22 +1,34 @@ { "config": { "abort": { - "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada" + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" }, "error": { "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", "no_stations": "No se pudo encontrar ninguna estaci\u00f3n al alcance." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Clave API" + } + }, "select_station": { "data": { "stations": "Estaciones" }, + "description": "encontr\u00f3 {stations_count} estaciones en el radio", "title": "Selecciona las estaciones a a\u00f1adir" }, "user": { "data": { - "name": "Nombre de la regi\u00f3n" + "api_key": "Clave API", + "fuel_types": "Tipos de combustible", + "location": "Ubicaci\u00f3n", + "name": "Nombre de la regi\u00f3n", + "radius": "Radio de b\u00fasqueda", + "stations": "Estaciones de servicio adicionales" } } } @@ -25,8 +37,11 @@ "step": { "init": { "data": { - "show_on_map": "Muestra las estaciones en el mapa" - } + "scan_interval": "Intervalo de actualizaci\u00f3n", + "show_on_map": "Muestra las estaciones en el mapa", + "stations": "Estaciones" + }, + "title": "Opciones de Tankerkoenig" } } } diff --git a/homeassistant/components/tankerkoenig/translations/et.json b/homeassistant/components/tankerkoenig/translations/et.json index c15e4b78ddf..028bac46d44 100644 --- a/homeassistant/components/tankerkoenig/translations/et.json +++ b/homeassistant/components/tankerkoenig/translations/et.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Asukoht on juba m\u00e4\u00e4ratud" + "already_configured": "Asukoht on juba m\u00e4\u00e4ratud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_auth": "Tuvastamine nurjus", "no_stations": "Piirkonnas ei leitud \u00fchtegi tanklat" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API v\u00f5ti" + } + }, "select_station": { "data": { "stations": "Tanklad" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "V\u00e4rskendamise intervall", - "show_on_map": "N\u00e4ita jaamu kaardil" + "show_on_map": "N\u00e4ita jaamu kaardil", + "stations": "Tanklad" }, "title": "Tankerkoenig valikud" } diff --git a/homeassistant/components/tankerkoenig/translations/fr.json b/homeassistant/components/tankerkoenig/translations/fr.json index 1a52eee5d39..410150263ab 100644 --- a/homeassistant/components/tankerkoenig/translations/fr.json +++ b/homeassistant/components/tankerkoenig/translations/fr.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification non valide", "no_stations": "Aucune station-service n'a \u00e9t\u00e9 trouv\u00e9e dans le rayon indiqu\u00e9." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Cl\u00e9 d'API" + } + }, "select_station": { "data": { "stations": "Stations-services" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Intervalle de mise \u00e0 jour", - "show_on_map": "Afficher les stations-services sur la carte" + "show_on_map": "Afficher les stations-services sur la carte", + "stations": "Stations-services" }, "title": "Options Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/hu.json b/homeassistant/components/tankerkoenig/translations/hu.json index e2c31e9e354..502ccd6fd9c 100644 --- a/homeassistant/components/tankerkoenig/translations/hu.json +++ b/homeassistant/components/tankerkoenig/translations/hu.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "no_stations": "Nem tal\u00e1lhat\u00f3 \u00e1llom\u00e1s a hat\u00f3t\u00e1vols\u00e1gon bel\u00fcl." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API kulcs" + } + }, "select_station": { "data": { "stations": "\u00c1llom\u00e1sok" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Friss\u00edt\u00e9si id\u0151k\u00f6z", - "show_on_map": "\u00c1llom\u00e1sok megjelen\u00edt\u00e9se a t\u00e9rk\u00e9pen" + "show_on_map": "\u00c1llom\u00e1sok megjelen\u00edt\u00e9se a t\u00e9rk\u00e9pen", + "stations": "\u00c1llom\u00e1sok" }, "title": "Tankerkoenig be\u00e1ll\u00edt\u00e1sok" } diff --git a/homeassistant/components/tankerkoenig/translations/id.json b/homeassistant/components/tankerkoenig/translations/id.json index cddeb02b17f..ed0e2e15104 100644 --- a/homeassistant/components/tankerkoenig/translations/id.json +++ b/homeassistant/components/tankerkoenig/translations/id.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Lokasi sudah dikonfigurasi" + "already_configured": "Lokasi sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "invalid_auth": "Autentikasi tidak valid", "no_stations": "Tidak dapat menemukan SPBU dalam jangkauan." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Kunci API" + } + }, "select_station": { "data": { "stations": "SPBU" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Interval pembaruan", - "show_on_map": "Tampilkan SPBU di peta" + "show_on_map": "Tampilkan SPBU di peta", + "stations": "SPBU" }, "title": "Opsi Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/it.json b/homeassistant/components/tankerkoenig/translations/it.json index e24c353d4f1..b98598d10a3 100644 --- a/homeassistant/components/tankerkoenig/translations/it.json +++ b/homeassistant/components/tankerkoenig/translations/it.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "La posizione \u00e8 gi\u00e0 configurata" + "already_configured": "La posizione \u00e8 gi\u00e0 configurata", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_auth": "Autenticazione non valida", "no_stations": "Impossibile trovare nessuna stazione nel raggio d'azione." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Chiave API" + } + }, "select_station": { "data": { "stations": "Stazioni" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Intervallo di aggiornamento", - "show_on_map": "Mostra stazioni sulla mappa" + "show_on_map": "Mostra stazioni sulla mappa", + "stations": "Stazioni" }, "title": "Opzioni Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/ja.json b/homeassistant/components/tankerkoenig/translations/ja.json index 687e05322d5..8ee4aae3b88 100644 --- a/homeassistant/components/tankerkoenig/translations/ja.json +++ b/homeassistant/components/tankerkoenig/translations/ja.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" }, "error": { "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", "no_stations": "\u7bc4\u56f2\u5185\u306b\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc" + } + }, "select_station": { "data": { "stations": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "\u66f4\u65b0\u9593\u9694", - "show_on_map": "\u5730\u56f3\u4e0a\u306b\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u8868\u793a\u3059\u308b" + "show_on_map": "\u5730\u56f3\u4e0a\u306b\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u8868\u793a\u3059\u308b", + "stations": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3" }, "title": "Tankerkoenig\u30aa\u30d7\u30b7\u30e7\u30f3" } diff --git a/homeassistant/components/tankerkoenig/translations/nl.json b/homeassistant/components/tankerkoenig/translations/nl.json index 66d442a71f6..57a8a1fcfe7 100644 --- a/homeassistant/components/tankerkoenig/translations/nl.json +++ b/homeassistant/components/tankerkoenig/translations/nl.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Locatie is al geconfigureerd" + "already_configured": "Locatie is al geconfigureerd", + "reauth_successful": "Herauthenticatie geslaagd" }, "error": { "invalid_auth": "Ongeldige authenticatie", "no_stations": "Kon geen station in bereik vinden." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-sleutel" + } + }, "select_station": { "data": { "stations": "Stations" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Update Interval", - "show_on_map": "Toon stations op kaart" + "show_on_map": "Toon stations op kaart", + "stations": "Stations" }, "title": "Tankerkoenig opties" } diff --git a/homeassistant/components/tankerkoenig/translations/no.json b/homeassistant/components/tankerkoenig/translations/no.json index 9d0b6ddab52..369ac4d3ce4 100644 --- a/homeassistant/components/tankerkoenig/translations/no.json +++ b/homeassistant/components/tankerkoenig/translations/no.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Plasseringen er allerede konfigurert" + "already_configured": "Plasseringen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", "no_stations": "Kunne ikke finne noen stasjon innen rekkevidde." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-n\u00f8kkel" + } + }, "select_station": { "data": { "stations": "Stasjoner" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Oppdateringsintervall", - "show_on_map": "Vis stasjoner p\u00e5 kart" + "show_on_map": "Vis stasjoner p\u00e5 kart", + "stations": "Stasjoner" }, "title": "Tankerkoenig alternativer" } diff --git a/homeassistant/components/tankerkoenig/translations/pl.json b/homeassistant/components/tankerkoenig/translations/pl.json index e13ae2c4783..288b4f8aae7 100644 --- a/homeassistant/components/tankerkoenig/translations/pl.json +++ b/homeassistant/components/tankerkoenig/translations/pl.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Lokalizacja jest ju\u017c skonfigurowana" + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "invalid_auth": "Niepoprawne uwierzytelnienie", "no_stations": "Nie mo\u017cna znale\u017a\u0107 \u017cadnej stacji w zasi\u0119gu." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Klucz API" + } + }, "select_station": { "data": { "stations": "Stacje" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji", - "show_on_map": "Poka\u017c stacje na mapie" + "show_on_map": "Poka\u017c stacje na mapie", + "stations": "Stacje" }, "title": "Opcje Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/pt-BR.json b/homeassistant/components/tankerkoenig/translations/pt-BR.json index b24e0b1dca8..af26b6167b3 100644 --- a/homeassistant/components/tankerkoenig/translations/pt-BR.json +++ b/homeassistant/components/tankerkoenig/translations/pt-BR.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Localiza\u00e7\u00e3o j\u00e1 est\u00e1 configurada" + "already_configured": "Localiza\u00e7\u00e3o j\u00e1 est\u00e1 configurada", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "error": { "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "no_stations": "N\u00e3o foi poss\u00edvel encontrar nenhum posto ao alcance." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Chave da API" + } + }, "select_station": { "data": { "stations": "Postos de combustiveis" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "Intervalo de atualiza\u00e7\u00e3o", - "show_on_map": "Mostrar postos no mapa" + "show_on_map": "Mostrar postos no mapa", + "stations": "Esta\u00e7\u00f5es" }, "title": "Op\u00e7\u00f5es de Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/sv.json b/homeassistant/components/tankerkoenig/translations/sv.json new file mode 100644 index 00000000000..4b9b566c7d1 --- /dev/null +++ b/homeassistant/components/tankerkoenig/translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tankerkoenig/translations/tr.json b/homeassistant/components/tankerkoenig/translations/tr.json index 2d88d2fa670..ca0038b6dbb 100644 --- a/homeassistant/components/tankerkoenig/translations/tr.json +++ b/homeassistant/components/tankerkoenig/translations/tr.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "no_stations": "Menzilde herhangi bir istasyon bulunamad\u0131." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Anahtar\u0131" + } + }, "select_station": { "data": { "stations": "\u0130stasyonlar" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "G\u00fcncelle\u015ftirme aral\u0131\u011f\u0131", - "show_on_map": "\u0130stasyonlar\u0131 haritada g\u00f6ster" + "show_on_map": "\u0130stasyonlar\u0131 haritada g\u00f6ster", + "stations": "\u0130stasyonlar" }, "title": "Tankerkoenig se\u00e7enekleri" } diff --git a/homeassistant/components/tankerkoenig/translations/zh-Hant.json b/homeassistant/components/tankerkoenig/translations/zh-Hant.json index 059d07ccdc6..1e1a5d6e15a 100644 --- a/homeassistant/components/tankerkoenig/translations/zh-Hant.json +++ b/homeassistant/components/tankerkoenig/translations/zh-Hant.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "no_stations": "\u7bc4\u570d\u5167\u627e\u4e0d\u5230\u4efb\u4f55\u52a0\u6cb9\u7ad9\u3002" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u91d1\u9470" + } + }, "select_station": { "data": { "stations": "\u52a0\u6cb9\u7ad9" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "\u66f4\u65b0\u983b\u7387", - "show_on_map": "\u65bc\u5730\u5716\u986f\u793a\u52a0\u6cb9\u7ad9" + "show_on_map": "\u65bc\u5730\u5716\u986f\u793a\u52a0\u6cb9\u7ad9", + "stations": "\u52a0\u6cb9\u7ad9" }, "title": "Tankerkoenig \u9078\u9805" } diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index fe6eeb9e303..339ec6eb895 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -1,7 +1,7 @@ """The Tautulli integration.""" from __future__ import annotations -from pytautulli import PyTautulli, PyTautulliHostConfiguration +from pytautulli import PyTautulli, PyTautulliApiUser, PyTautulliHostConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform @@ -50,14 +50,18 @@ class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]): self, coordinator: TautulliDataUpdateCoordinator, description: EntityDescription, + user: PyTautulliApiUser | None = None, ) -> None: """Initialize the Tautulli entity.""" super().__init__(coordinator) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self.user = user self._attr_device_info = DeviceInfo( configuration_url=coordinator.host_configuration.base_url, entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, user.user_id if user else entry_id)}, manufacturer=DEFAULT_NAME, + name=user.username if user else DEFAULT_NAME, ) diff --git a/homeassistant/components/tautulli/config_flow.py b/homeassistant/components/tautulli/config_flow.py index ea470e2e1d0..f06405825c9 100644 --- a/homeassistant/components/tautulli/config_flow.py +++ b/homeassistant/components/tautulli/config_flow.py @@ -1,38 +1,18 @@ """Config flow for Tautulli.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any -from pytautulli import ( - PyTautulli, - PyTautulliException, - PyTautulliHostConfiguration, - exceptions, -) +from pytautulli import PyTautulli, PyTautulliException, exceptions import voluptuous as vol -from homeassistant.components.sensor import _LOGGER from homeassistant.config_entries import ConfigFlow -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_PATH, - CONF_PORT, - CONF_SSL, - CONF_URL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - DEFAULT_NAME, - DEFAULT_PATH, - DEFAULT_PORT, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import DEFAULT_NAME, DOMAIN class TautulliConfigFlow(ConfigFlow, domain=DOMAIN): @@ -70,7 +50,7 @@ class TautulliConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_reauth_confirm() @@ -94,33 +74,6 @@ class TautulliConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - _LOGGER.warning( - "Configuration of the Tautulli platform in YAML is deprecated and will be " - "removed in Home Assistant 2022.6; Your existing configuration for host %s" - "has been imported into the UI automatically and can be safely removed " - "from your configuration.yaml file", - config[CONF_HOST], - ) - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - host_configuration = PyTautulliHostConfiguration( - config[CONF_API_KEY], - ipaddress=config[CONF_HOST], - port=config.get(CONF_PORT, DEFAULT_PORT), - ssl=config.get(CONF_SSL, DEFAULT_SSL), - verify_ssl=config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), - base_api_path=config.get(CONF_PATH, DEFAULT_PATH), - ) - return await self.async_step_user( - { - CONF_API_KEY: host_configuration.api_token, - CONF_URL: host_configuration.base_url, - CONF_VERIFY_SSL: host_configuration.verify_ssl, - } - ) - async def validate_input(self, user_input: dict[str, Any]) -> str | None: """Try connecting to Tautulli.""" try: diff --git a/homeassistant/components/tautulli/const.py b/homeassistant/components/tautulli/const.py index 5c0a1b56cda..c0ca923c3e5 100644 --- a/homeassistant/components/tautulli/const.py +++ b/homeassistant/components/tautulli/const.py @@ -1,11 +1,9 @@ """Constants for the Tautulli integration.""" from logging import Logger, getLogger +ATTR_TOP_USER = "top_user" + CONF_MONITORED_USERS = "monitored_users" DEFAULT_NAME = "Tautulli" -DEFAULT_PATH = "" -DEFAULT_PORT = "8181" -DEFAULT_SSL = False -DEFAULT_VERIFY_SSL = True DOMAIN = "tautulli" LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index b1af6e3ce47..1d5efde7cc7 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,64 +1,215 @@ """A platform which allows you to get information from Tautulli.""" from __future__ import annotations -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast -import voluptuous as vol +from pytautulli import ( + PyTautulliApiActivity, + PyTautulliApiHomeStats, + PyTautulliApiSession, + PyTautulliApiUser, +) from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_PATH, - CONF_PORT, - CONF_SSL, - CONF_VERIFY_SSL, -) +from homeassistant.const import DATA_KILOBITS, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityCategory, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from . import TautulliEntity -from .const import ( - CONF_MONITORED_USERS, - DEFAULT_NAME, - DEFAULT_PATH, - DEFAULT_PORT, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import ATTR_TOP_USER, DOMAIN from .coordinator import TautulliDataUpdateCoordinator -# Deprecated in Home Assistant 2022.4 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_MONITORED_USERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, - vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - } -) -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +def get_top_stats( + home_stats: PyTautulliApiHomeStats, activity: PyTautulliApiActivity, key: str +) -> str | None: + """Get top statistics.""" + value = None + for stat in home_stats: + if stat.rows and stat.stat_id == key: + value = stat.rows[0].title + elif stat.rows and stat.stat_id == "top_users" and key == ATTR_TOP_USER: + value = stat.rows[0].user + return value + + +@dataclass +class TautulliSensorEntityMixin: + """Mixin for Tautulli sensor.""" + + value_fn: Callable[[PyTautulliApiHomeStats, PyTautulliApiActivity, str], StateType] + + +@dataclass +class TautulliSensorEntityDescription( + SensorEntityDescription, TautulliSensorEntityMixin +): + """Describes a Tautulli sensor.""" + + +SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( + TautulliSensorEntityDescription( icon="mdi:plex", key="watching_count", name="Tautulli", native_unit_of_measurement="Watching", + value_fn=lambda home_stats, activity, _: cast(int, activity.stream_count), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_direct_play", + name="Direct Plays", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_direct_play + ), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_direct_stream", + name="Direct Streams", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_direct_stream + ), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_transcode", + name="Transcodes", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_transcode + ), + ), + TautulliSensorEntityDescription( + key="total_bandwidth", + name="Total Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.total_bandwidth), + ), + TautulliSensorEntityDescription( + key="lan_bandwidth", + name="LAN Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.lan_bandwidth), + ), + TautulliSensorEntityDescription( + key="wan_bandwidth", + name="WAN Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.wan_bandwidth), + ), + TautulliSensorEntityDescription( + icon="mdi:movie-open", + key="top_movies", + name="Top Movie", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), + TautulliSensorEntityDescription( + icon="mdi:television", + key="top_tv", + name="Top TV Show", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), + TautulliSensorEntityDescription( + icon="mdi:walk", + key=ATTR_TOP_USER, + name="Top User", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), +) + + +@dataclass +class TautulliSessionSensorEntityMixin: + """Mixin for Tautulli session sensor.""" + + value_fn: Callable[[PyTautulliApiSession], StateType] + + +@dataclass +class TautulliSessionSensorEntityDescription( + SensorEntityDescription, TautulliSessionSensorEntityMixin +): + """Describes a Tautulli session sensor.""" + + +SESSION_SENSOR_TYPES: tuple[TautulliSessionSensorEntityDescription, ...] = ( + TautulliSessionSensorEntityDescription( + icon="mdi:plex", + key="state", + name="State", + value_fn=lambda session: cast(str, session.state), + ), + TautulliSessionSensorEntityDescription( + key="full_title", + name="Full Title", + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.full_title), + ), + TautulliSessionSensorEntityDescription( + icon="mdi:progress-clock", + key="progress", + name="Progress", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.progress_percent), + ), + TautulliSessionSensorEntityDescription( + key="stream_resolution", + name="Stream Resolution", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.stream_video_resolution), + ), + TautulliSessionSensorEntityDescription( + icon="mdi:plex", + key="transcode_decision", + name="Transcode Decision", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.transcode_decision), + ), + TautulliSessionSensorEntityDescription( + key="session_thumb", + name="session Thumbnail", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.user_thumb), + ), + TautulliSessionSensorEntityDescription( + key="video_resolution", + name="Video Resolution", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.video_resolution), ), ) @@ -82,62 +233,64 @@ async def async_setup_entry( ) -> None: """Set up Tautulli sensor.""" coordinator: TautulliDataUpdateCoordinator = hass.data[DOMAIN] - async_add_entities( + entities: list[TautulliSensor | TautulliSessionSensor] = [ TautulliSensor( coordinator, description, ) for description in SENSOR_TYPES - ) + ] + if coordinator.users: + entities.extend( + TautulliSessionSensor( + coordinator, + description, + user, + ) + for description in SESSION_SENSOR_TYPES + for user in coordinator.users + if user.username != "Local" + ) + async_add_entities(entities) class TautulliSensor(TautulliEntity, SensorEntity): """Representation of a Tautulli sensor.""" + entity_description: TautulliSensorEntityDescription + @property def native_value(self) -> StateType: """Return the state of the sensor.""" - if not self.coordinator.activity: - return 0 - return self.coordinator.activity.stream_count or 0 + return self.entity_description.value_fn( + self.coordinator.home_stats, + self.coordinator.activity, + self.entity_description.key, + ) + + +class TautulliSessionSensor(TautulliEntity, SensorEntity): + """Representation of a Tautulli session sensor.""" + + entity_description: TautulliSessionSensorEntityDescription + + def __init__( + self, + coordinator: TautulliDataUpdateCoordinator, + description: EntityDescription, + user: PyTautulliApiUser, + ) -> None: + """Initialize the Tautulli entity.""" + super().__init__(coordinator, description, user) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{user.user_id}_{description.key}" + self._attr_name = f"{user.username} {description.name}" @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return attributes for the sensor.""" - if ( - not self.coordinator.activity - or not self.coordinator.home_stats - or not self.coordinator.users - ): - return None - - _attributes = { - "stream_count": self.coordinator.activity.stream_count, - "stream_count_direct_play": self.coordinator.activity.stream_count_direct_play, - "stream_count_direct_stream": self.coordinator.activity.stream_count_direct_stream, - "stream_count_transcode": self.coordinator.activity.stream_count_transcode, - "total_bandwidth": self.coordinator.activity.total_bandwidth, - "lan_bandwidth": self.coordinator.activity.lan_bandwidth, - "wan_bandwidth": self.coordinator.activity.wan_bandwidth, - } - - for stat in self.coordinator.home_stats: - if stat.stat_id == "top_movies": - _attributes["Top Movie"] = stat.rows[0].title if stat.rows else None - elif stat.stat_id == "top_tv": - _attributes["Top TV Show"] = stat.rows[0].title if stat.rows else None - elif stat.stat_id == "top_users": - _attributes["Top User"] = stat.rows[0].user if stat.rows else None - - for user in self.coordinator.users: - if user.username == "Local": - continue - _attributes.setdefault(user.username, {})["Activity"] = None - - for session in self.coordinator.activity.sessions: - if not _attributes.get(session.username) or "null" in session.state: - continue - - _attributes[session.username]["Activity"] = session.state - - return _attributes + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if self.coordinator.activity: + for session in self.coordinator.activity.sessions: + if self.user and session.user_id == self.user.user_id: + return self.entity_description.value_fn(session) + return None diff --git a/homeassistant/components/tautulli/translations/es.json b/homeassistant/components/tautulli/translations/es.json index 0d6fd1d3b9e..295683bf358 100644 --- a/homeassistant/components/tautulli/translations/es.json +++ b/homeassistant/components/tautulli/translations/es.json @@ -5,20 +5,25 @@ "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida" + "cannot_connect": "Fallo en la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { "data": { "api_key": "Clave API" }, + "description": "Para encontrar su clave API, abra la p\u00e1gina web de Tautulli y navegue a Configuraci\u00f3n y luego a la interfaz web. La clave API estar\u00e1 en la parte inferior de esa p\u00e1gina.", "title": "Re-autenticaci\u00f3n de Tautulli" }, "user": { "data": { + "api_key": "Clave API", "url": "URL", "verify_ssl": "Verifica el certificat SSL" - } + }, + "description": "Para encontrar su clave de API, abra la p\u00e1gina web de Tautulli y navegue hasta Configuraci\u00f3n y luego hasta Interfaz web. La clave API estar\u00e1 en la parte inferior de esa p\u00e1gina.\n\nEjemplo de la URL: ```http://192.168.0.10:8181`` con 8181 como puerto predeterminado." } } } diff --git a/homeassistant/components/tautulli/translations/sv.json b/homeassistant/components/tautulli/translations/sv.json index 3354c6053dc..0058ba541bf 100644 --- a/homeassistant/components/tautulli/translations/sv.json +++ b/homeassistant/components/tautulli/translations/sv.json @@ -3,6 +3,11 @@ "step": { "reauth_confirm": { "description": "F\u00f6r att hitta din API-nyckel, \u00f6ppna Tautullis webbsida och navigera till Inst\u00e4llningar och sedan till webbgr\u00e4nssnitt. API-nyckeln finns l\u00e4ngst ner p\u00e5 sidan." + }, + "user": { + "data": { + "api_key": "API-nyckel" + } } } } diff --git a/homeassistant/components/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json index 1ab57418af5..8852c0184b2 100644 --- a/homeassistant/components/ted5000/manifest.json +++ b/homeassistant/components/ted5000/manifest.json @@ -2,7 +2,7 @@ "domain": "ted5000", "name": "The Energy Detective TED5000", "documentation": "https://www.home-assistant.io/integrations/ted5000", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 29dbabcbbfe..be1c8325c5f 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -80,6 +80,12 @@ ATTR_VERIFY_SSL = "verify_ssl" ATTR_TIMEOUT = "timeout" ATTR_MESSAGE_TAG = "message_tag" ATTR_CHANNEL_POST = "channel_post" +ATTR_QUESTION = "question" +ATTR_OPTIONS = "options" +ATTR_ANSWERS = "answers" +ATTR_OPEN_PERIOD = "open_period" +ATTR_IS_ANONYMOUS = "is_anonymous" +ATTR_ALLOWS_MULTIPLE_ANSWERS = "allows_multiple_answers" CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_PROXY_URL = "proxy_url" @@ -96,6 +102,7 @@ SERVICE_SEND_VIDEO = "send_video" SERVICE_SEND_VOICE = "send_voice" SERVICE_SEND_DOCUMENT = "send_document" SERVICE_SEND_LOCATION = "send_location" +SERVICE_SEND_POLL = "send_poll" SERVICE_EDIT_MESSAGE = "edit_message" SERVICE_EDIT_CAPTION = "edit_caption" SERVICE_EDIT_REPLYMARKUP = "edit_replymarkup" @@ -184,6 +191,19 @@ SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend( } ) +SERVICE_SCHEMA_SEND_POLL = vol.Schema( + { + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(ATTR_QUESTION): cv.string, + vol.Required(ATTR_OPTIONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_OPEN_PERIOD): cv.positive_int, + vol.Optional(ATTR_IS_ANONYMOUS, default=True): cv.boolean, + vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_TIMEOUT): cv.positive_int, + } +) + SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend( { vol.Required(ATTR_MESSAGEID): vol.Any( @@ -246,6 +266,7 @@ SERVICE_MAP = { SERVICE_SEND_VOICE: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, + SERVICE_SEND_POLL: SERVICE_SCHEMA_SEND_POLL, SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, @@ -399,6 +420,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job( partial(notify_service.send_location, **kwargs) ) + elif msgtype == SERVICE_SEND_POLL: + await hass.async_add_executor_job( + partial(notify_service.send_poll, **kwargs) + ) elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: await hass.async_add_executor_job( partial(notify_service.answer_callback_query, **kwargs) @@ -847,6 +872,34 @@ class TelegramNotificationService: timeout=params[ATTR_TIMEOUT], ) + def send_poll( + self, + question, + options, + is_anonymous, + allows_multiple_answers, + target=None, + **kwargs, + ): + """Send a poll.""" + params = self._get_msg_kwargs(kwargs) + openperiod = kwargs.get(ATTR_OPEN_PERIOD) + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) + self._send_msg( + self.bot.send_poll, + "Error sending poll", + params[ATTR_MESSAGE_TAG], + chat_id=chat_id, + question=question, + options=options, + is_anonymous=is_anonymous, + allows_multiple_answers=allows_multiple_answers, + open_period=openperiod, + disable_notification=params[ATTR_DISABLE_NOTIF], + timeout=params[ATTR_TIMEOUT], + ) + def leave_chat(self, chat_id=None): """Remove bot from chat.""" chat_id = self._get_target_chat_ids(chat_id)[0] diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 6afd42dffb8..31876bd542d 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -678,6 +678,60 @@ send_location: selector: text: +send_poll: + name: Send poll + description: Send a poll. + fields: + target: + name: Target + description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default. + example: "[12345, 67890] or 12345" + selector: + object: + question: + name: Question + description: Poll question, 1-300 characters + required: true + selector: + text: + options: + name: Options + description: List of answer options, 2-10 strings 1-100 characters each + required: true + selector: + object: + is_anonymous: + name: Is Anonymous + description: If the poll needs to be anonymous, defaults to True + selector: + boolean: + allows_multiple_answers: + name: Allow Multiple Answers + description: If the poll allows multiple answers, defaults to False + selector: + boolean: + open_period: + name: Open Period + description: Amount of time in seconds the poll will be active after creation, 5-600. + selector: + number: + min: 5 + max: 600 + unit_of_measurement: seconds + disable_notification: + name: Disable notification + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + selector: + boolean: + timeout: + name: Timeout + description: Timeout for send poll. Will help with timeout errors (poor internet connection, etc) + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds + edit_message: name: Edit message description: Edit a previously sent message. diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index b64c3f887a0..829478fc990 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -1,4 +1,6 @@ """Support for Tellstick covers using Tellstick Net.""" +from typing import Any + from homeassistant.components import cover, tellduslive from homeassistant.components.cover import CoverEntity from homeassistant.config_entries import ConfigEntry @@ -6,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import TelldusLiveClient from .entry import TelldusLiveEntity @@ -18,7 +21,7 @@ async def async_setup_entry( async def async_discover_cover(device_id): """Discover and add a discovered sensor.""" - client = hass.data[tellduslive.DOMAIN] + client: TelldusLiveClient = hass.data[tellduslive.DOMAIN] async_add_entities([TelldusLiveCover(client, device_id)]) async_dispatcher_connect( @@ -32,21 +35,21 @@ class TelldusLiveCover(TelldusLiveEntity, CoverEntity): """Representation of a cover.""" @property - def is_closed(self): + def is_closed(self) -> bool: """Return the current position of the cover.""" return self.device.is_down - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self.device.down() self._update_callback() - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.device.up() self._update_callback() - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self.device.stop() self._update_callback() diff --git a/homeassistant/components/tellstick/cover.py b/homeassistant/components/tellstick/cover.py index 65f11bdbae6..17da5684670 100644 --- a/homeassistant/components/tellstick/cover.py +++ b/homeassistant/components/tellstick/cover.py @@ -1,6 +1,8 @@ """Support for Tellstick covers.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import CoverEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -42,24 +44,24 @@ class TellstickCover(TellstickDevice, CoverEntity): """Representation of a Tellstick cover.""" @property - def is_closed(self): + def is_closed(self) -> None: """Return the current position of the cover is not possible.""" return None @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return True - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._tellcore_device.down() - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._tellcore_device.up() - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._tellcore_device.stop() diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index a8a88c57bd3..ae26e58ac04 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -125,6 +125,8 @@ async def async_setup_platform( class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): """Representation of a templated Alarm Control Panel.""" + _attr_should_poll = False + def __init__( self, hass, @@ -142,8 +144,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): name = self._attr_name self._template = config.get(CONF_VALUE_TEMPLATE) self._disarm_script = None - self._code_arm_required = config[CONF_CODE_ARM_REQUIRED] - self._code_format = config[CONF_CODE_FORMAT] + self._code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] + self._code_format: TemplateCodeFormat = config[CONF_CODE_FORMAT] 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 @@ -156,10 +158,10 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): if (arm_night_action := config.get(CONF_ARM_NIGHT_ACTION)) is not None: self._arm_night_script = Script(hass, arm_night_action, name, DOMAIN) - self._state = None + self._state: str | None = None @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self._state @@ -185,12 +187,12 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): return supported_features @property - def code_format(self): + 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): + def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self._code_arm_required @@ -214,7 +216,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): ) self._state = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._template: self.add_template_attribute( @@ -237,25 +239,25 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): if optimistic_set: self.async_write_ha_state() - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Arm the panel to Away.""" await self._async_alarm_arm( STATE_ALARM_ARMED_AWAY, script=self._arm_away_script, code=code ) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Arm the panel to Home.""" await self._async_alarm_arm( STATE_ALARM_ARMED_HOME, script=self._arm_home_script, code=code ) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Arm the panel to Night.""" await self._async_alarm_arm( STATE_ALARM_ARMED_NIGHT, script=self._arm_night_script, code=code ) - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Disarm the panel.""" await self._async_alarm_arm( STATE_ALARM_DISARMED, script=self._disarm_script, code=code diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 2a537e2aa6b..ab7c88e8b8c 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -82,9 +82,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DELAY_ON): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) @@ -195,6 +193,8 @@ async def async_setup_platform( class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index ac83f76ca91..2bb2f40d6b4 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -78,6 +78,8 @@ async def async_setup_platform( class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 82c5cc2578c..aad0270e434 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -11,6 +12,7 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -133,6 +135,8 @@ async def async_setup_platform( class CoverTemplate(TemplateEntity, CoverEntity): """Representation of a Template cover.""" + _attr_should_poll = False + def __init__( self, hass, @@ -151,7 +155,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 = config.get(CONF_DEVICE_CLASS) + self._device_class: CoverDeviceClass | None = 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) @@ -178,7 +182,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._is_closing = False self._tilt_value = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._template: self.add_template_attribute( @@ -267,22 +271,22 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._tilt_value = state @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._position == 0 @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is currently opening.""" return self._is_opening @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is currently closing.""" return self._is_closing @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -292,7 +296,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): return None @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. None is unknown, 0 is closed, 100 is fully open. @@ -300,12 +304,12 @@ class CoverTemplate(TemplateEntity, CoverEntity): return self._tilt_value @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Return the device class of the cover.""" return self._device_class @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -320,7 +324,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): return supported_features - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._open_script: await self.async_run_script(self._open_script, context=self._context) @@ -334,7 +338,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._position = 100 self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" if self._close_script: await self.async_run_script(self._close_script, context=self._context) @@ -348,12 +352,12 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._position = 0 self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" if self._stop_script: await self.async_run_script(self._stop_script, context=self._context) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set cover position.""" self._position = kwargs[ATTR_POSITION] await self.async_run_script( @@ -364,7 +368,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): if self._optimistic: self.async_write_ha_state() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover open.""" self._tilt_value = 100 await self.async_run_script( @@ -375,7 +379,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): if self._tilt_optimistic: self.async_write_ha_state() - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover closed.""" self._tilt_value = 0 await self.async_run_script( @@ -386,7 +390,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): if self._tilt_optimistic: self.async_write_ha_state() - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] await self.async_run_script( diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 2c8d7247967..b60e7f53364 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -54,7 +54,6 @@ CONF_DIRECTION_TEMPLATE = "direction_template" CONF_ON_ACTION = "turn_on" CONF_OFF_ACTION = "turn_off" CONF_SET_PERCENTAGE_ACTION = "set_percentage" -CONF_SET_SPEED_ACTION = "set_speed" CONF_SET_OSCILLATING_ACTION = "set_oscillating" CONF_SET_DIRECTION_ACTION = "set_direction" CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" @@ -126,6 +125,8 @@ async def async_setup_platform( class TemplateFan(TemplateEntity, FanEntity): """A template fan component.""" + _attr_should_poll = False + def __init__( self, hass, @@ -153,12 +154,6 @@ class TemplateFan(TemplateEntity, FanEntity): self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - self._set_speed_script = None - if set_speed_action := config.get(CONF_SET_SPEED_ACTION): - self._set_speed_script = Script( - hass, set_speed_action, friendly_name, DOMAIN - ) - self._set_percentage_script = None if set_percentage_action := config.get(CONF_SET_PERCENTAGE_ACTION): self._set_percentage_script = Script( @@ -220,35 +215,35 @@ class TemplateFan(TemplateEntity, FanEntity): return self._preset_modes @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._state == STATE_ON @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset mode.""" return self._preset_mode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed percentage.""" return self._percentage @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return the oscillation state.""" return self._oscillating @property - def current_direction(self): + def current_direction(self) -> str | None: """Return the oscillation state.""" return self._direction async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.async_run_script( @@ -359,7 +354,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) self._state = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.add_template_attribute("_state", self._template, None, self._update_state) if self._preset_mode_template is not None: diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index eafb0b3f4d0..807e3e79ef8 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -136,6 +136,8 @@ async def async_setup_platform( class LightTemplate(TemplateEntity, LightEntity): """Representation of a templated Light, including dimmable.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 1d94194be63..da8be80d8a4 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -1,6 +1,8 @@ """Support for locks which integrates with other components.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.lock import ( @@ -70,6 +72,8 @@ async def async_setup_platform( class TemplateLock(TemplateEntity, LockEntity): """Representation of a template lock.""" + _attr_should_poll = False + def __init__( self, hass, @@ -88,27 +92,27 @@ class TemplateLock(TemplateEntity, LockEntity): self._optimistic = config.get(CONF_OPTIMISTIC) @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" - return self._optimistic + return bool(self._optimistic) @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state in ("true", STATE_ON, STATE_LOCKED) @property - def is_jammed(self): + def is_jammed(self) -> bool: """Return true if lock is jammed.""" return self._state == STATE_JAMMED @property - def is_unlocking(self): + def is_unlocking(self) -> bool: """Return true if lock is unlocking.""" return self._state == STATE_UNLOCKING @property - def is_locking(self): + def is_locking(self) -> bool: """Return true if lock is locking.""" return self._state == STATE_LOCKING @@ -129,21 +133,21 @@ class TemplateLock(TemplateEntity, LockEntity): self._state = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.add_template_attribute( "_state", self._state_template, None, self._update_state ) await super().async_added_to_hass() - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" if self._optimistic: self._state = True self.async_write_ha_state() await self.async_run_script(self._command_lock, context=self._context) - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" if self._optimistic: self._state = False diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 54131990a26..d41bdee597b 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -100,6 +100,8 @@ async def async_setup_platform( class TemplateNumber(TemplateEntity, NumberEntity): """Representation of a template number.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, @@ -117,45 +119,45 @@ class TemplateNumber(TemplateEntity, NumberEntity): self._min_value_template = config[ATTR_MIN] self._max_value_template = config[ATTR_MAX] self._attr_assumed_state = self._optimistic = config[CONF_OPTIMISTIC] - self._attr_value = None - self._attr_step = None - self._attr_min_value = None - self._attr_max_value = None + self._attr_native_value = None + self._attr_native_step = None + self._attr_native_min_value = None + self._attr_native_max_value = None async def async_added_to_hass(self) -> None: """Register callbacks.""" self.add_template_attribute( - "_attr_value", + "_attr_native_value", self._value_template, validator=vol.Coerce(float), none_on_template_error=True, ) self.add_template_attribute( - "_attr_step", + "_attr_native_step", self._step_template, validator=vol.Coerce(float), none_on_template_error=True, ) if self._min_value_template is not None: self.add_template_attribute( - "_attr_min_value", + "_attr_native_min_value", self._min_value_template, validator=vol.Coerce(float), none_on_template_error=True, ) if self._max_value_template is not None: self.add_template_attribute( - "_attr_max_value", + "_attr_native_max_value", self._max_value_template, validator=vol.Coerce(float), none_on_template_error=True, ) await super().async_added_to_hass() - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value of the number.""" if self._optimistic: - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() await self.async_run_script( self._command_set_value, @@ -191,35 +193,35 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): ) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the currently selected option.""" return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE)) @property - def min_value(self) -> int: + def native_min_value(self) -> int: """Return the minimum value.""" return vol.Any(vol.Coerce(float), None)( - self._rendered.get(ATTR_MIN, super().min_value) + self._rendered.get(ATTR_MIN, super().native_min_value) ) @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the maximum value.""" return vol.Any(vol.Coerce(float), None)( - self._rendered.get(ATTR_MAX, super().max_value) + self._rendered.get(ATTR_MAX, super().native_max_value) ) @property - def step(self) -> int: + def native_step(self) -> int: """Return the increment/decrement step.""" return vol.Any(vol.Coerce(float), None)( - self._rendered.get(ATTR_STEP, super().step) + self._rendered.get(ATTR_STEP, super().native_step) ) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value of the number.""" if self._config[CONF_OPTIMISTIC]: - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() await self._command_set_value.async_run( {ATTR_VALUE: value}, context=self._context diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 19f17096178..4aa36a378ab 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -94,6 +94,8 @@ async def async_setup_platform( class TemplateSelect(TemplateEntity, SelectEntity): """Representation of a template select.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 126dd551c45..ee1ddfa8c2d 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -12,10 +12,8 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, - SensorEntity, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( @@ -39,6 +37,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import ( + TEMPLATE_SENSOR_BASE_SCHEMA, + TemplateSensor, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -49,7 +51,6 @@ from .const import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -61,16 +62,15 @@ LEGACY_FIELDS = { } -SENSOR_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) +SENSOR_SCHEMA = ( + vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + } + ) + .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) +) LEGACY_SENSOR_SCHEMA = vol.All( @@ -192,9 +192,11 @@ async def async_setup_platform( ) -class SensorTemplate(TemplateEntity, SensorEntity): +class SensorTemplate(TemplateSensor): """Representation of a Template Sensor.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, @@ -202,17 +204,13 @@ class SensorTemplate(TemplateEntity, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - super().__init__(hass, config=config, unique_id=unique_id) + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + self._template = config.get(CONF_STATE) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - self._template = config.get(CONF_STATE) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_state_class = config.get(CONF_STATE_CLASS) - async def async_added_to_hass(self): """Register callbacks.""" self.add_template_attribute( diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index ac01bc66812..f04f2b5ba7a 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -90,6 +90,8 @@ async def async_setup_platform( class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" + _attr_should_poll = False + def __init__( self, hass, @@ -149,11 +151,6 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): """Return true if device is on.""" return self._state - @property - def should_poll(self): - """Return the polling state.""" - return False - async def async_turn_on(self, **kwargs): """Fire the on action.""" await self.async_run_script(self._on_script, context=self._context) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 6e0b7f6f48f..901834237c6 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -1,38 +1,23 @@ """TemplateEntity utility class.""" from __future__ import annotations -from collections.abc import Callable -import contextlib import itertools -import logging from typing import Any import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, - EVENT_HOMEASSISTANT_START, - STATE_UNKNOWN, ) -from homeassistant.core import Context, CoreState, Event, State, callback -from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import ( - TrackTemplate, - TrackTemplateResult, - async_track_template_result, -) -from homeassistant.helpers.script import Script, _VarsType -from homeassistant.helpers.template import ( - Template, - TemplateStateFromEntityId, - result_as_boolean, +from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( # noqa: F401 pylint: disable=unused-import + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, ) from .const import ( @@ -43,9 +28,6 @@ from .const import ( CONF_PICTURE, ) -_LOGGER = logging.getLogger(__name__) - - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema( { vol.Optional(CONF_AVAILABILITY): cv.template, @@ -62,10 +44,8 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), vol.Optional(CONF_AVAILABILITY): cv.template, - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_PICTURE): cv.template, } -) +).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY = vol.Schema( { @@ -121,356 +101,3 @@ def rewrite_common_legacy_to_modern_conf( entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME]) return entity_cfg - - -class _TemplateAttribute: - """Attribute value linked to template result.""" - - def __init__( - self, - entity: Entity, - attribute: str, - template: Template, - validator: Callable[[Any], Any] = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool | None = False, - ) -> None: - """Template attribute.""" - self._entity = entity - self._attribute = attribute - self.template = template - self.validator = validator - self.on_update = on_update - self.async_update = None - self.none_on_template_error = none_on_template_error - - @callback - def async_setup(self): - """Config update path for the attribute.""" - if self.on_update: - return - - if not hasattr(self._entity, self._attribute): - raise AttributeError(f"Attribute '{self._attribute}' does not exist.") - - self.on_update = self._default_update - - @callback - def _default_update(self, result): - attr_result = None if isinstance(result, TemplateError) else result - setattr(self._entity, self._attribute, attr_result) - - @callback - def handle_result( - self, - event: Event | None, - template: Template, - last_result: str | None | TemplateError, - result: str | TemplateError, - ) -> None: - """Handle a template result event callback.""" - if isinstance(result, TemplateError): - _LOGGER.error( - "TemplateError('%s') " - "while processing template '%s' " - "for attribute '%s' in entity '%s'", - result, - self.template, - self._attribute, - self._entity.entity_id, - ) - if self.none_on_template_error: - self._default_update(result) - else: - assert self.on_update - self.on_update(result) - return - - if not self.validator: - assert self.on_update - self.on_update(result) - return - - try: - validated = self.validator(result) - except vol.Invalid as ex: - _LOGGER.error( - "Error validating template result '%s' " - "from template '%s' " - "for attribute '%s' in entity %s " - "validation message '%s'", - result, - self.template, - self._attribute, - self._entity.entity_id, - ex.msg, - ) - assert self.on_update - self.on_update(None) - return - - assert self.on_update - self.on_update(validated) - return - - -class TemplateEntity(Entity): - """Entity that uses templates to calculate attributes.""" - - _attr_available = True - _attr_entity_picture = None - _attr_icon = None - _attr_should_poll = False - - def __init__( - self, - hass, - *, - availability_template=None, - icon_template=None, - entity_picture_template=None, - attribute_templates=None, - config=None, - fallback_name=None, - unique_id=None, - ): - """Template Entity.""" - self._template_attrs = {} - self._async_update = None - self._attr_extra_state_attributes = {} - self._self_ref_update_count = 0 - self._attr_unique_id = unique_id - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - - class DummyState(State): - """None-state for template entities not yet added to the state machine.""" - - def __init__(self) -> None: - """Initialize a new state.""" - super().__init__("unknown.unknown", STATE_UNKNOWN) - self.entity_id = None # type: ignore[assignment] - - @property - def name(self) -> str: - """Name of this state.""" - return "" - - variables = {"this": DummyState()} - - # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name - if self._friendly_name_template: - self._friendly_name_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_name = self._friendly_name_template.async_render( - variables=variables, parse_result=False - ) - - # Templates will not render while the entity is unavailable, try to render the - # icon and picture templates. - if self._entity_picture_template: - self._entity_picture_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_entity_picture = self._entity_picture_template.async_render( - variables=variables, parse_result=False - ) - - if self._icon_template: - self._icon_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_icon = self._icon_template.async_render( - variables=variables, parse_result=False - ) - - @callback - def _update_available(self, result): - if isinstance(result, TemplateError): - self._attr_available = True - return - - self._attr_available = result_as_boolean(result) - - @callback - def _update_state(self, result): - if self._availability_template: - return - - self._attr_available = not isinstance(result, TemplateError) - - @callback - def _add_attribute_template(self, attribute_key, attribute_template): - """Create a template tracker for the attribute.""" - - def _update_attribute(result): - attr_result = None if isinstance(result, TemplateError) else result - self._attr_extra_state_attributes[attribute_key] = attr_result - - self.add_template_attribute( - attribute_key, attribute_template, None, _update_attribute - ) - - def add_template_attribute( - self, - attribute: str, - template: Template, - validator: Callable[[Any], Any] = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool = False, - ) -> None: - """ - Call in the constructor to add a template linked to a attribute. - - Parameters - ---------- - attribute - The name of the attribute to link to. This attribute must exist - unless a custom on_update method is supplied. - template - The template to calculate. - validator - Validator function to parse the result and ensure it's valid. - on_update - Called to store the template result rather than storing it - the supplied attribute. Passed the result of the validator, or None - if the template or validator resulted in an error. - - """ - assert self.hass is not None, "hass cannot be None" - template.hass = self.hass - template_attribute = _TemplateAttribute( - self, attribute, template, validator, on_update, none_on_template_error - ) - self._template_attrs.setdefault(template, []) - self._template_attrs[template].append(template_attribute) - - @callback - def _handle_results( - self, - event: Event | None, - updates: list[TrackTemplateResult], - ) -> None: - """Call back the results to the attributes.""" - if event: - self.async_set_context(event.context) - - entity_id = event and event.data.get(ATTR_ENTITY_ID) - - if entity_id and entity_id == self.entity_id: - self._self_ref_update_count += 1 - else: - self._self_ref_update_count = 0 - - if self._self_ref_update_count > len(self._template_attrs): - for update in updates: - _LOGGER.warning( - "Template loop detected while processing event: %s, skipping template render for Template[%s]", - event, - update.template.template, - ) - return - - for update in updates: - for attr in self._template_attrs[update.template]: - attr.handle_result( - event, update.template, update.last_result, update.result - ) - - self.async_write_ha_state() - - async def _async_template_startup(self, *_) -> None: - template_var_tups: list[TrackTemplate] = [] - has_availability_template = False - - variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} - - for template, attributes in self._template_attrs.items(): - template_var_tup = TrackTemplate(template, variables) - is_availability_template = False - for attribute in attributes: - # pylint: disable-next=protected-access - if attribute._attribute == "_attr_available": - has_availability_template = True - is_availability_template = True - attribute.async_setup() - # Insert the availability template first in the list - if is_availability_template: - template_var_tups.insert(0, template_var_tup) - else: - template_var_tups.append(template_var_tup) - - result_info = async_track_template_result( - self.hass, - template_var_tups, - self._handle_results, - has_super_template=has_availability_template, - ) - self.async_on_remove(result_info.async_remove) - self._async_update = result_info.async_refresh - result_info.async_refresh() - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - if self._availability_template is not None: - self.add_template_attribute( - "_attr_available", - self._availability_template, - None, - self._update_available, - ) - if self._attribute_templates is not None: - for key, value in self._attribute_templates.items(): - self._add_attribute_template(key, value) - if self._icon_template is not None: - self.add_template_attribute( - "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) - ) - if self._entity_picture_template is not None: - self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template - ) - if ( - self._friendly_name_template is not None - and not self._friendly_name_template.is_static - ): - self.add_template_attribute("_attr_name", self._friendly_name_template) - - if self.hass.state == CoreState.running: - await self._async_template_startup() - return - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_template_startup - ) - - async def async_update(self) -> None: - """Call for forced update.""" - self._async_update() - - async def async_run_script( - self, - script: Script, - *, - run_variables: _VarsType | None = None, - context: Context | None = None, - ) -> None: - """Run an action script.""" - if run_variables is None: - run_variables = {} - return await script.async_run( - run_variables={ - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **run_variables, - }, - context=context, - ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 6780d12c507..b5696003c94 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -147,25 +147,32 @@ class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): @callback def _process_data(self) -> None: """Process new data.""" + + this = None + if state := self.hass.states.get(self.entity_id): + this = state.as_dict() + run_variables = self.coordinator.data["run_variables"] + variables = {"this": this, **(run_variables or {})} + try: rendered = dict(self._static_rendered) for key in self._to_render_simple: rendered[key] = self._config[key].async_render( - self.coordinator.data["run_variables"], + variables, parse_result=key in self._parse_result, ) for key in self._to_render_complex: rendered[key] = template.render_complex( self._config[key], - self.coordinator.data["run_variables"], + variables, ) if CONF_ATTRIBUTES in self._config: rendered[CONF_ATTRIBUTES] = template.render_complex( self._config[CONF_ATTRIBUTES], - self.coordinator.data["run_variables"], + variables, ) self._rendered = rendered diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 4b278ef6aec..5f306bfa5e1 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -126,6 +126,8 @@ async def async_setup_platform( class TemplateVacuum(TemplateEntity, StateVacuumEntity): """A template vacuum component.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index d4069096553..d65e03b9656 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -20,15 +20,22 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ENTITY_ID_FORMAT, + Forecast, WeatherEntity, ) -from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant 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.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import ( + distance as distance_util, + pressure as pressure_util, + speed as speed_util, + temperature as temp_util, +) from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf @@ -61,6 +68,10 @@ CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_TEMPLATE = "forecast_template" +CONF_PRESSURE_UNIT = "pressure_unit" +CONF_WIND_SPEED_UNIT = "wind_speed_unit" +CONF_VISIBILITY_UNIT = "visibility_unit" +CONF_PRECIPITATION_UNIT = "precipitation_unit" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -76,6 +87,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(temp_util.VALID_UNITS), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(pressure_util.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(speed_util.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(distance_util.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(distance_util.VALID_UNITS), } ) @@ -105,12 +121,14 @@ async def async_setup_platform( class WeatherTemplate(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_should_poll = False + def __init__( self, - hass, - config, - unique_id, - ): + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: """Initialize the Template weather.""" super().__init__(hass, config=config, unique_id=unique_id) @@ -126,6 +144,12 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_template = config.get(CONF_FORECAST_TEMPLATE) + 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.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) self._condition = None @@ -137,66 +161,61 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._wind_bearing = None self._ozone = None self._visibility = None - self._forecast = [] + self._forecast: list[Forecast] = [] @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" return self._condition @property - def temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" return self._temperature @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self.hass.config.units.temperature_unit - - @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self._humidity @property - def wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" return self._wind_speed @property - def wind_bearing(self): + def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" return self._wind_bearing @property - def ozone(self): + def ozone(self) -> float | None: """Return the ozone level.""" return self._ozone @property - def visibility(self): + def native_visibility(self) -> float | None: """Return the visibility.""" return self._visibility @property - def pressure(self): + def native_pressure(self) -> float | None: """Return the air pressure.""" return self._pressure @property - def forecast(self): + def forecast(self) -> list[Forecast]: """Return the forecast.""" return self._forecast @property - def attribution(self): + def attribution(self) -> str | None: """Return the attribution.""" if self._attribution is None: return "Powered by Home Assistant" return self._attribution - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._condition_template: diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index efd4f3d76d0..42d0eae1ecd 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -6,7 +6,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.1", - "numpy==1.21.6", + "numpy==1.23.0", "pillow==9.1.1" ], "codeowners": [], diff --git a/homeassistant/components/threshold/translations/es.json b/homeassistant/components/threshold/translations/es.json index 0200c840243..585a690108f 100644 --- a/homeassistant/components/threshold/translations/es.json +++ b/homeassistant/components/threshold/translations/es.json @@ -1,22 +1,36 @@ { "config": { + "error": { + "need_lower_upper": "Los l\u00edmites superior e inferior no pueden estar vac\u00edos" + }, "step": { "user": { "data": { "entity_id": "Sensor de entrada", "hysteresis": "Hist\u00e9resis", "lower": "L\u00edmite inferior", + "name": "Nombre", "upper": "L\u00edmite superior" - } + }, + "description": "Cree un sensor binario que se encienda y apague dependiendo del valor de un sensor \n\n Solo l\u00edmite inferior configurado: se enciende cuando el valor del sensor de entrada es menor que el l\u00edmite inferior.\n Solo l\u00edmite superior configurado: se enciende cuando el valor del sensor de entrada es mayor que el l\u00edmite superior.\n Ambos l\u00edmites inferior y superior configurados: se activa cuando el valor del sensor de entrada est\u00e1 en el rango [l\u00edmite inferior ... l\u00edmite superior].", + "title": "A\u00f1adir sensor de umbral" } } }, "options": { + "error": { + "need_lower_upper": "Los l\u00edmites superior e inferior no pueden estar vac\u00edos" + }, "step": { "init": { "data": { - "hysteresis": "Hist\u00e9resis" - } + "entity_id": "Sensor de entrada", + "hysteresis": "Hist\u00e9resis", + "lower": "L\u00edmite inferior", + "name": "Nombre", + "upper": "L\u00edmite superior" + }, + "description": "Solo l\u00edmite inferior configurado: se enciende cuando el valor del sensor de entrada es menor que el l\u00edmite inferior.\n Solo l\u00edmite superior configurado: se enciende cuando el valor del sensor de entrada es mayor que el l\u00edmite superior.\n Ambos l\u00edmites inferior y superior configurados: se activa cuando el valor del sensor de entrada est\u00e1 en el rango [l\u00edmite inferior ... l\u00edmite superior]." } } }, diff --git a/homeassistant/components/threshold/translations/ja.json b/homeassistant/components/threshold/translations/ja.json index 1978fa4f3c5..821de593499 100644 --- a/homeassistant/components/threshold/translations/ja.json +++ b/homeassistant/components/threshold/translations/ja.json @@ -12,7 +12,7 @@ "name": "\u540d\u524d", "upper": "\u4e0a\u9650\u5024" }, - "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002", + "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u5185\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002", "title": "\u65b0\u3057\u3044\u3057\u304d\u3044\u5024\u30bb\u30f3\u30b5\u30fc" } } @@ -30,9 +30,9 @@ "name": "\u540d\u524d", "upper": "\u4e0a\u9650\u5024" }, - "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002" + "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u5185\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002" } } }, - "title": "\u3057\u304d\u3044\u5024\u30bb\u30f3\u30b5\u30fc" + "title": "\u95be\u5024\u30bb\u30f3\u30b5\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/threshold/translations/sv.json b/homeassistant/components/threshold/translations/sv.json new file mode 100644 index 00000000000..613b2c25412 --- /dev/null +++ b/homeassistant/components/threshold/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "need_lower_upper": "Undre och \u00f6vre gr\u00e4ns kan inte vara tomma" + } + }, + "options": { + "step": { + "init": { + "data": { + "lower": "Undre gr\u00e4ns" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tibber/translations/sv.json b/homeassistant/components/tibber/translations/sv.json new file mode 100644 index 00000000000..1fda5b91f5a --- /dev/null +++ b/homeassistant/components/tibber/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tile/config_flow.py b/homeassistant/components/tile/config_flow.py index e1424453075..3ba1dc411ae 100644 --- a/homeassistant/components/tile/config_flow.py +++ b/homeassistant/components/tile/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Tile integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytile import async_login @@ -74,9 +75,9 @@ class TileFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/tile/translations/sv.json b/homeassistant/components/tile/translations/sv.json new file mode 100644 index 00000000000..26e9f2d6a49 --- /dev/null +++ b/homeassistant/components/tile/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tod/translations/es.json b/homeassistant/components/tod/translations/es.json index 302dc6cfdd9..c1ea525e10c 100644 --- a/homeassistant/components/tod/translations/es.json +++ b/homeassistant/components/tod/translations/es.json @@ -4,7 +4,8 @@ "user": { "data": { "after_time": "Tiempo de activaci\u00f3n", - "before_time": "Tiempo de desactivaci\u00f3n" + "before_time": "Tiempo de desactivaci\u00f3n", + "name": "Nombre" }, "description": "Crea un sensor binario que se activa o desactiva en funci\u00f3n de la hora.", "title": "A\u00f1ade sensor tiempo del d\u00eda" @@ -15,7 +16,8 @@ "step": { "init": { "data": { - "after_time": "Tiempo de activaci\u00f3n" + "after_time": "Tiempo de activaci\u00f3n", + "before_time": "Tiempo apagado" } } } diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 85d80756020..a6767a50814 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -34,8 +34,8 @@ class ToloNumberEntityDescription( """Class describing TOLO Number entities.""" entity_category = EntityCategory.CONFIG - min_value = 0 - step = 1 + native_min_value = 0 + native_step = 1 NUMBERS = ( @@ -43,8 +43,8 @@ NUMBERS = ( key="power_timer", icon="mdi:power-settings", name="Power Timer", - unit_of_measurement=TIME_MINUTES, - max_value=POWER_TIMER_MAX, + native_unit_of_measurement=TIME_MINUTES, + native_max_value=POWER_TIMER_MAX, getter=lambda settings: settings.power_timer, setter=lambda client, value: client.set_power_timer(value), ), @@ -52,8 +52,8 @@ NUMBERS = ( key="salt_bath_timer", icon="mdi:shaker-outline", name="Salt Bath Timer", - unit_of_measurement=TIME_MINUTES, - max_value=SALT_BATH_TIMER_MAX, + native_unit_of_measurement=TIME_MINUTES, + native_max_value=SALT_BATH_TIMER_MAX, getter=lambda settings: settings.salt_bath_timer, setter=lambda client, value: client.set_salt_bath_timer(value), ), @@ -61,8 +61,8 @@ NUMBERS = ( key="fan_timer", icon="mdi:fan-auto", name="Fan Timer", - unit_of_measurement=TIME_MINUTES, - max_value=FAN_TIMER_MAX, + native_unit_of_measurement=TIME_MINUTES, + native_max_value=FAN_TIMER_MAX, getter=lambda settings: settings.fan_timer, setter=lambda client, value: client.set_fan_timer(value), ), @@ -98,11 +98,11 @@ class ToloNumberEntity(ToloSaunaCoordinatorEntity, NumberEntity): self._attr_unique_id = f"{entry.entry_id}_{entity_description.key}" @property - def value(self) -> float: + def native_value(self) -> float: """Return the value of this TOLO Number entity.""" return self.entity_description.getter(self.coordinator.data.settings) or 0 - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Set the value of this TOLO Number entity.""" int_value = int(value) if int_value == 0: diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index d8decc1aea3..cef4662d1a4 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -1,6 +1,7 @@ """The Tomorrow.io integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from math import ceil @@ -23,7 +24,6 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, - CONF_NAME, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -40,7 +40,6 @@ from .const import ( CONF_TIMESTEP, DOMAIN, INTEGRATION_NAME, - MAX_REQUESTS_PER_DAY, TMRW_ATTR_CARBON_MONOXIDE, TMRW_ATTR_CHINA_AQI, TMRW_ATTR_CHINA_HEALTH_CONCERN, @@ -85,36 +84,33 @@ PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] @callback -def async_set_update_interval( - hass: HomeAssistant, current_entry: ConfigEntry -) -> timedelta: - """Recalculate update_interval based on existing Tomorrow.io instances and update them.""" - api_calls = 2 - # We check how many Tomorrow.io configured instances are using the same API key and - # calculate interval to not exceed allowed numbers of requests. Divide 90% of - # MAX_REQUESTS_PER_DAY by the number of API calls because we want a buffer in the - # number of API calls left at the end of the day. - other_instance_entry_ids = [ - entry.entry_id +def async_get_entries_by_api_key( + hass: HomeAssistant, api_key: str, exclude_entry: ConfigEntry | None = None +) -> list[ConfigEntry]: + """Get all entries for a given API key.""" + return [ + entry for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id != current_entry.entry_id - and entry.data[CONF_API_KEY] == current_entry.data[CONF_API_KEY] + if entry.data[CONF_API_KEY] == api_key + and (exclude_entry is None or exclude_entry != entry) ] - interval = timedelta( - minutes=( - ceil( - (24 * 60 * (len(other_instance_entry_ids) + 1) * api_calls) - / (MAX_REQUESTS_PER_DAY * 0.9) - ) - ) + +@callback +def async_set_update_interval( + hass: HomeAssistant, api: TomorrowioV4, exclude_entry: ConfigEntry | None = None +) -> timedelta: + """Calculate update_interval.""" + # We check how many Tomorrow.io configured instances are using the same API key and + # calculate interval to not exceed allowed numbers of requests. Divide 90% of + # max_requests by the number of API calls because we want a buffer in the + # number of API calls left at the end of the day. + entries = async_get_entries_by_api_key(hass, api.api_key, exclude_entry) + minutes = ceil( + (24 * 60 * len(entries) * api.num_api_requests) + / (api.max_requests_per_day * 0.9) ) - - for entry_id in other_instance_entry_ids: - if entry_id in hass.data[DOMAIN]: - hass.data[DOMAIN][entry_id].update_interval = interval - - return interval + return timedelta(minutes=minutes) @callback @@ -197,24 +193,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.source == SOURCE_IMPORT and "old_config_entry_id" in entry.data: async_migrate_entry_from_climacell(hass, dev_reg, entry, device) - api = TomorrowioV4( - entry.data[CONF_API_KEY], - entry.data[CONF_LOCATION][CONF_LATITUDE], - entry.data[CONF_LOCATION][CONF_LONGITUDE], - unit_system="metric", - session=async_get_clientsession(hass), - ) + api_key = entry.data[CONF_API_KEY] + # If coordinator already exists for this API key, we'll use that, otherwise + # we have to create a new one + if not (coordinator := hass.data[DOMAIN].get(api_key)): + session = async_get_clientsession(hass) + # we will not use the class's lat and long so we can pass in garbage + # lats and longs + api = TomorrowioV4(api_key, 361.0, 361.0, unit_system="metric", session=session) + coordinator = TomorrowioDataUpdateCoordinator(hass, api) + hass.data[DOMAIN][api_key] = coordinator - coordinator = TomorrowioDataUpdateCoordinator( - hass, - entry, - api, - async_set_update_interval(hass, entry), - ) - - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id] = coordinator + await coordinator.async_setup_entry(entry) hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -227,9 +217,13 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) - hass.data[DOMAIN].pop(config_entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + api_key = config_entry.data[CONF_API_KEY] + coordinator: TomorrowioDataUpdateCoordinator = hass.data[DOMAIN][api_key] + # If this is true, we can remove the coordinator + if await coordinator.async_unload_entry(config_entry): + hass.data[DOMAIN].pop(api_key) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) return unload_ok @@ -237,44 +231,90 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold Tomorrow.io data.""" - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - api: TomorrowioV4, - update_interval: timedelta, - ) -> None: + def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: """Initialize.""" - - self._config_entry = config_entry self._api = api - self.name = config_entry.data[CONF_NAME] self.data = {CURRENT: {}, FORECASTS: {}} + self.entry_id_to_location_dict: dict[str, str] = {} + self._coordinator_ready: asyncio.Event | None = None - super().__init__( - hass, - _LOGGER, - name=config_entry.data[CONF_NAME], - update_interval=update_interval, - ) + super().__init__(hass, _LOGGER, name=f"{DOMAIN}_{self._api.api_key}") + + def add_entry_to_location_dict(self, entry: ConfigEntry) -> None: + """Add an entry to the location dict.""" + latitude = entry.data[CONF_LOCATION][CONF_LATITUDE] + longitude = entry.data[CONF_LOCATION][CONF_LONGITUDE] + self.entry_id_to_location_dict[entry.entry_id] = f"{latitude},{longitude}" + + async def async_setup_entry(self, entry: ConfigEntry) -> None: + """Load config entry into coordinator.""" + # If we haven't loaded any data yet, register all entries with this API key and + # get the initial data for all of them. We do this because another config entry + # may start setup before we finish setting the initial data and we don't want + # to do multiple refreshes on startup. + if self._coordinator_ready is None: + self._coordinator_ready = asyncio.Event() + for entry_ in async_get_entries_by_api_key(self.hass, self._api.api_key): + self.add_entry_to_location_dict(entry_) + await self.async_config_entry_first_refresh() + self._coordinator_ready.set() + else: + # If we have an event, we need to wait for it to be set before we proceed + await self._coordinator_ready.wait() + # If we're not getting new data because we already know this entry, we + # don't need to schedule a refresh + if entry.entry_id in self.entry_id_to_location_dict: + return + # We need a refresh, but it's going to be a partial refresh so we can + # minimize repeat API calls + self.add_entry_to_location_dict(entry) + await self.async_refresh() + + self.update_interval = async_set_update_interval(self.hass, self._api) + self._schedule_refresh() + + async def async_unload_entry(self, entry: ConfigEntry) -> bool | None: + """ + Unload a config entry from coordinator. + + Returns whether coordinator can be removed as well because there are no + config entries tied to it anymore. + """ + self.entry_id_to_location_dict.pop(entry.entry_id) + self.update_interval = async_set_update_interval(self.hass, self._api, entry) + return not self.entry_id_to_location_dict async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - try: - return await self._api.realtime_and_all_forecasts( - [ - TMRW_ATTR_TEMPERATURE, - TMRW_ATTR_HUMIDITY, - TMRW_ATTR_PRESSURE, - TMRW_ATTR_WIND_SPEED, - TMRW_ATTR_WIND_DIRECTION, - TMRW_ATTR_CONDITION, - TMRW_ATTR_VISIBILITY, - TMRW_ATTR_OZONE, - TMRW_ATTR_WIND_GUST, - TMRW_ATTR_CLOUD_COVER, - TMRW_ATTR_PRECIPITATION_TYPE, - *( + data = {} + # If we are refreshing because of a new config entry that's not already in our + # data, we do a partial refresh to avoid wasted API calls. + if self.data and any( + entry_id not in self.data for entry_id in self.entry_id_to_location_dict + ): + data = self.data + + for entry_id, location in self.entry_id_to_location_dict.items(): + if entry_id in data: + continue + entry = self.hass.config_entries.async_get_entry(entry_id) + assert entry + try: + data[entry_id] = await self._api.realtime_and_all_forecasts( + [ + # Weather + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_PRECIPITATION_TYPE, + # Sensors TMRW_ATTR_CARBON_MONOXIDE, TMRW_ATTR_CHINA_AQI, TMRW_ATTR_CHINA_HEALTH_CONCERN, @@ -300,26 +340,28 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator): TMRW_ATTR_SOLAR_GHI, TMRW_ATTR_SULPHUR_DIOXIDE, TMRW_ATTR_WIND_GUST, - ), - ], - [ - TMRW_ATTR_TEMPERATURE_LOW, - TMRW_ATTR_TEMPERATURE_HIGH, - TMRW_ATTR_WIND_SPEED, - TMRW_ATTR_WIND_DIRECTION, - TMRW_ATTR_CONDITION, - TMRW_ATTR_PRECIPITATION, - TMRW_ATTR_PRECIPITATION_PROBABILITY, - ], - nowcast_timestep=self._config_entry.options[CONF_TIMESTEP], - ) - except ( - CantConnectException, - InvalidAPIKeyException, - RateLimitedException, - UnknownException, - ) as error: - raise UpdateFailed from error + ], + [ + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + ], + nowcast_timestep=entry.options[CONF_TIMESTEP], + location=location, + ) + except ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, + ) as error: + raise UpdateFailed from error + + return data class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): @@ -349,7 +391,8 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): Used for V4 API. """ - return self.coordinator.data.get(CURRENT, {}).get(property_name) + entry_id = self._config_entry.entry_id + return self.coordinator.data[entry_id].get(CURRENT, {}).get(property_name) @property def attribution(self): diff --git a/homeassistant/components/tomorrowio/manifest.json b/homeassistant/components/tomorrowio/manifest.json index a577ec517c1..5447b90d1ce 100644 --- a/homeassistant/components/tomorrowio/manifest.json +++ b/homeassistant/components/tomorrowio/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tomorrowio", "requirements": ["pytomorrowio==0.3.3"], - "codeowners": ["@raman325"], + "codeowners": ["@raman325", "@lymanepp"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index d221922df54..ea114af0544 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -23,11 +23,11 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + CONF_API_KEY, CONF_NAME, IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, IRRADIATION_WATTS_PER_SQUARE_METER, LENGTH_KILOMETERS, - LENGTH_METERS, LENGTH_MILES, PERCENTAGE, PRESSURE_HPA, @@ -39,6 +39,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.distance import convert as distance_convert +from homeassistant.util.speed import convert as speed_convert from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity from .const import ( @@ -112,14 +113,14 @@ SENSOR_TYPES = ( native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), - # Data comes in as inHg + # Data comes in as hPa TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, ), - # Data comes in as BTUs/(hr * ft^2) + # 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, @@ -128,7 +129,7 @@ SENSOR_TYPES = ( unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, imperial_conversion=(1 / 3.15459), ), - # Data comes in as miles + # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", @@ -138,7 +139,7 @@ SENSOR_TYPES = ( val, LENGTH_KILOMETERS, LENGTH_MILES ), ), - # Data comes in as miles + # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", @@ -153,16 +154,15 @@ SENSOR_TYPES = ( name="Cloud Cover", native_unit_of_measurement=PERCENTAGE, ), - # Data comes in as MPH + # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( key=TMRW_ATTR_WIND_GUST, name="Wind Gust", unit_imperial=SPEED_MILES_PER_HOUR, unit_metric=SPEED_METERS_PER_SECOND, - imperial_conversion=lambda val: distance_convert( - val, LENGTH_METERS, LENGTH_MILES - ) - * 3600, + imperial_conversion=lambda val: speed_convert( + val, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR + ), ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRECIPITATION_TYPE, @@ -171,7 +171,7 @@ SENSOR_TYPES = ( device_class="tomorrowio__precipitation_type", icon="mdi:weather-snowy-rainy", ), - # Data comes in as ppb + # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Ozone is 48 TomorrowioSensorEntityDescription( key=TMRW_ATTR_OZONE, @@ -192,7 +192,7 @@ SENSOR_TYPES = ( native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, ), - # Data comes in as ppb + # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 TomorrowioSensorEntityDescription( key=TMRW_ATTR_NITROGEN_DIOXIDE, @@ -201,7 +201,7 @@ SENSOR_TYPES = ( multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, ), - # Data comes in as ppb + # Data comes in as ppb, convert to ppm TomorrowioSensorEntityDescription( key=TMRW_ATTR_CARBON_MONOXIDE, name="Carbon Monoxide", @@ -209,6 +209,7 @@ SENSOR_TYPES = ( multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, ), + # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 TomorrowioSensorEntityDescription( key=TMRW_ATTR_SULPHUR_DIOXIDE, @@ -286,7 +287,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] entities = [ TomorrowioSensorEntity(hass, config_entry, coordinator, 4, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/tomorrowio/translations/es.json b/homeassistant/components/tomorrowio/translations/es.json index e2f36a0949e..bacc07bcdfc 100644 --- a/homeassistant/components/tomorrowio/translations/es.json +++ b/homeassistant/components/tomorrowio/translations/es.json @@ -3,6 +3,7 @@ "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "invalid_api_key": "Clave API inv\u00e1lida", + "rate_limited": "Actualmente la tarifa est\u00e1 limitada, por favor int\u00e9ntelo m\u00e1s tarde.", "unknown": "Error inesperado" }, "step": { @@ -21,7 +22,9 @@ "init": { "data": { "timestep": "Min. entre previsiones de NowCast" - } + }, + "description": "Si elige habilitar la entidad de pron\u00f3stico \"nowcast\", puede configurar el n\u00famero de minutos entre cada pron\u00f3stico. El n\u00famero de pron\u00f3sticos proporcionados depende del n\u00famero de minutos elegidos entre los pron\u00f3sticos.", + "title": "Actualizar las opciones de Tomorrow.io" } } } diff --git a/homeassistant/components/tomorrowio/translations/sensor.es.json b/homeassistant/components/tomorrowio/translations/sensor.es.json index 03820d30265..3967aeba2e2 100644 --- a/homeassistant/components/tomorrowio/translations/sensor.es.json +++ b/homeassistant/components/tomorrowio/translations/sensor.es.json @@ -1,15 +1,26 @@ { "state": { "tomorrowio__health_concern": { + "good": "Bueno", + "hazardous": "Peligroso", + "moderate": "Moderado", "unhealthy": "Poco saludable", "unhealthy_for_sensitive_groups": "No saludable para grupos sensibles", "very_unhealthy": "Nada saludable" }, "tomorrowio__pollen_index": { + "high": "Alto", + "low": "Bajo", "medium": "Medio", + "none": "Ninguna", + "very_high": "Muy alto", "very_low": "Muy bajo" }, "tomorrowio__precipitation_type": { + "freezing_rain": "Lluvia g\u00e9lida", + "ice_pellets": "Perdigones de hielo", + "none": "Ninguna", + "rain": "Lluvia", "snow": "Nieve" } } diff --git a/homeassistant/components/tomorrowio/translations/sensor.he.json b/homeassistant/components/tomorrowio/translations/sensor.he.json new file mode 100644 index 00000000000..a91a9b3255b --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/sensor.he.json @@ -0,0 +1,7 @@ +{ + "state": { + "tomorrowio__precipitation_type": { + "none": "\u05dc\u05dc\u05d0" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/translations/sv.json b/homeassistant/components/tomorrowio/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 52eedff49b1..07ea079b1ce 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -8,17 +8,18 @@ from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, LENGTH_KILOMETERS, LENGTH_MILLIMETERS, @@ -61,7 +62,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] entities = [ TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) @@ -73,11 +74,11 @@ async def async_setup_entry( class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" - _attr_temperature_unit = TEMP_CELSIUS - _attr_pressure_unit = PRESSURE_HPA - _attr_wind_speed_unit = SPEED_METERS_PER_SECOND - _attr_visibility_unit = LENGTH_KILOMETERS - _attr_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__( self, @@ -118,12 +119,12 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): data = { ATTR_FORECAST_TIME: forecast_dt.isoformat(), ATTR_FORECAST_CONDITION: translated_condition, - ATTR_FORECAST_PRECIPITATION: precipitation, + ATTR_FORECAST_NATIVE_PRECIPITATION: precipitation, ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, - ATTR_FORECAST_TEMP: temp, - ATTR_FORECAST_TEMP_LOW: temp_low, + ATTR_FORECAST_NATIVE_TEMP: temp, + ATTR_FORECAST_NATIVE_TEMP_LOW: temp_low, ATTR_FORECAST_WIND_BEARING: wind_direction, - ATTR_FORECAST_WIND_SPEED: wind_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: wind_speed, } return {k: v for k, v in data.items() if v is not None} @@ -144,12 +145,12 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): return CONDITIONS[condition] @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" return self._get_current_property(TMRW_ATTR_TEMPERATURE) @property - def pressure(self): + def native_pressure(self): """Return the raw pressure.""" return self._get_current_property(TMRW_ATTR_PRESSURE) @@ -159,7 +160,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): return self._get_current_property(TMRW_ATTR_HUMIDITY) @property - def wind_speed(self): + def native_wind_speed(self): """Return the raw wind speed.""" return self._get_current_property(TMRW_ATTR_WIND_SPEED) @@ -182,7 +183,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): ) @property - def visibility(self): + def native_visibility(self): """Return the raw visibility.""" return self._get_current_property(TMRW_ATTR_VISIBILITY) @@ -190,7 +191,11 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): def forecast(self): """Return the forecast.""" # Check if forecasts are available - raw_forecasts = self.coordinator.data.get(FORECASTS, {}).get(self.forecast_type) + raw_forecasts = ( + self.coordinator.data.get(self._config_entry.entry_id, {}) + .get(FORECASTS, {}) + .get(self.forecast_type) + ) if not raw_forecasts: return None diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 81c09931fbd..5819ff12743 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -47,11 +47,6 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() - async def register_webhook(self, event: Event | None = None) -> None: """Register a webhook with Toon to get live updates.""" if CONF_WEBHOOK_ID not in self.entry.data: @@ -128,7 +123,7 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): try: await self.toon.update(data["updateDataSet"]) - self.update_listeners() + self.async_update_listeners() except ToonError as err: _LOGGER.error("Could not process data received from Toon webhook - %s", err) diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index 405ecc36d7f..4fb4daede65 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -16,12 +16,12 @@ def toon_exception_handler(func): async def handler(self, *args, **kwargs): try: await func(self, *args, **kwargs) - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ToonConnectionError as error: _LOGGER.error("Error communicating with API: %s", error) self.coordinator.last_update_success = False - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ToonError as error: _LOGGER.error("Invalid response from API: %s", error) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index bf7a1ae410b..5798f4d31d3 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -1,4 +1,6 @@ """Interfaces with TotalConnect alarm control panels.""" +from __future__ import annotations + from total_connect_client import ArmingHelper from total_connect_client.exceptions import BadResultCodeError, UsercodeInvalid @@ -18,6 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -83,7 +86,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): self._partition = self._location.partitions[partition_id] self._device = self._location.devices[self._location.security_device_id] self._state = None - self._extra_state_attributes = {} + self._attr_extra_state_attributes = {} """ Set unique_id to location_id for partition 1 to avoid breaking change @@ -91,35 +94,25 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): Add _# for partition 2 and beyond. """ if partition_id == 1: - self._name = name - self._unique_id = f"{location_id}" + self._attr_name = name + self._attr_unique_id = f"{location_id}" else: - self._name = f"{name} partition {partition_id}" - self._unique_id = f"{location_id}_{partition_id}" + self._attr_name = f"{name} partition {partition_id}" + self._attr_unique_id = f"{location_id}_{partition_id}" @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.serial_number)}, - "name": self._device.name, - } + return DeviceInfo( + identifiers={(DOMAIN, self._device.serial_number)}, + name=self._device.name, + ) @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" attr = { - "location_name": self._name, + "location_name": self.name, "location_id": self._location_id, "partition": self._partition_id, "ac_loss": self._location.ac_loss, @@ -129,6 +122,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): "triggered_zone": None, } + state = None if self._partition.arming_state.is_disarmed(): state = STATE_ALARM_DISARMED elif self._partition.arming_state.is_armed_night(): @@ -154,16 +148,11 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): attr["triggered_source"] = "Carbon Monoxide" self._state = state - self._extra_state_attributes = attr + self._attr_extra_state_attributes = attr return self._state - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return self._extra_state_attributes - - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" try: await self.hass.async_add_executor_job(self._disarm) @@ -174,7 +163,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to disarm {self._name}." + f"TotalConnect failed to disarm {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -182,7 +171,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Disarm synchronous.""" ArmingHelper(self._partition).disarm() - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" try: await self.hass.async_add_executor_job(self._arm_home) @@ -193,7 +182,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm home {self._name}." + f"TotalConnect failed to arm home {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -201,7 +190,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm home synchronous.""" ArmingHelper(self._partition).arm_stay() - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" try: await self.hass.async_add_executor_job(self._arm_away) @@ -212,7 +201,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm away {self._name}." + f"TotalConnect failed to arm away {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -220,7 +209,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm away synchronous.""" ArmingHelper(self._partition).arm_away() - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" try: await self.hass.async_add_executor_job(self._arm_night) @@ -231,7 +220,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm night {self._name}." + f"TotalConnect failed to arm night {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -239,7 +228,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm night synchronous.""" ArmingHelper(self._partition).arm_stay_night() - async def async_alarm_arm_home_instant(self, code=None): + async def async_alarm_arm_home_instant(self, code: str | None = None) -> None: """Send arm home instant command.""" try: await self.hass.async_add_executor_job(self._arm_home_instant) @@ -250,7 +239,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm home instant {self._name}." + f"TotalConnect failed to arm home instant {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -258,7 +247,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm home instant synchronous.""" ArmingHelper(self._partition).arm_stay_instant() - async def async_alarm_arm_away_instant(self, code=None): + async def async_alarm_arm_away_instant(self, code: str | None = None) -> None: """Send arm away instant command.""" try: await self.hass.async_add_executor_job(self._arm_away_instant) @@ -269,7 +258,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm away instant {self._name}." + f"TotalConnect failed to arm away instant {self.name}." ) from error await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 49e60b5b46e..8d35506af0f 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -1,4 +1,9 @@ """Config flow for the Total Connect component.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + from total_connect_client.client import TotalConnectClient from total_connect_client.exceptions import AuthenticationError import voluptuous as vol @@ -6,6 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LOCATION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN @@ -119,10 +125,10 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"location_id": location_for_user}, ) - async def async_step_reauth(self, config): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an authentication error or no usercode.""" - self.username = config[CONF_USERNAME] - self.usercodes = config[CONF_USERCODES] + self.username = entry_data[CONF_USERNAME] + self.usercodes = entry_data[CONF_USERCODES] return await self.async_step_reauth_confirm() @@ -166,7 +172,9 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> TotalConnectOptionsFlowHandler: """Get options flow.""" return TotalConnectOptionsFlowHandler(config_entry) @@ -174,7 +182,7 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class TotalConnectOptionsFlowHandler(config_entries.OptionsFlow): """TotalConnect options flow handler.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/totalconnect/translations/bg.json b/homeassistant/components/totalconnect/translations/bg.json index 1858bd74b7b..e5aed3bb504 100644 --- a/homeassistant/components/totalconnect/translations/bg.json +++ b/homeassistant/components/totalconnect/translations/bg.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "step": { "locations": { diff --git a/homeassistant/components/totalconnect/translations/ca.json b/homeassistant/components/totalconnect/translations/ca.json index 404e07d6b69..36c1037d917 100644 --- a/homeassistant/components/totalconnect/translations/ca.json +++ b/homeassistant/components/totalconnect/translations/ca.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Bypass autom\u00e0tic de bateria baixa" + }, + "description": "Bypass autom\u00e0tic de les zones que informin de bateria baixa.", + "title": "Opcions de TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/de.json b/homeassistant/components/totalconnect/translations/de.json index 890bbb753d9..17b8ddca010 100644 --- a/homeassistant/components/totalconnect/translations/de.json +++ b/homeassistant/components/totalconnect/translations/de.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Automatische Umgehung bei schwacher Batterie" + }, + "description": "Automatische Umgehung von Zonen, sobald sie einen niedrigen Batteriestand melden.", + "title": "TotalConnect-Optionen" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/el.json b/homeassistant/components/totalconnect/translations/el.json index 4f60b082484..323fd58274c 100644 --- a/homeassistant/components/totalconnect/translations/el.json +++ b/homeassistant/components/totalconnect/translations/el.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "\u0391\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03c0\u03b1\u03c1\u03ac\u03ba\u03b1\u03bc\u03c8\u03b7 \u03c7\u03b1\u03bc\u03b7\u03bb\u03ae\u03c2 \u03bc\u03c0\u03b1\u03c4\u03b1\u03c1\u03af\u03b1\u03c2" + }, + "description": "\u0391\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03c0\u03b1\u03c1\u03ac\u03ba\u03b1\u03bc\u03c8\u03b7 \u03c4\u03c9\u03bd \u03b6\u03c9\u03bd\u03ce\u03bd \u03c4\u03b7 \u03c3\u03c4\u03b9\u03b3\u03bc\u03ae \u03c0\u03bf\u03c5 \u03b1\u03bd\u03b1\u03c6\u03ad\u03c1\u03bf\u03c5\u03bd \u03c7\u03b1\u03bc\u03b7\u03bb\u03ae \u03bc\u03c0\u03b1\u03c4\u03b1\u03c1\u03af\u03b1.", + "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2 TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/en.json b/homeassistant/components/totalconnect/translations/en.json index 8df2b00a936..f19b6196552 100644 --- a/homeassistant/components/totalconnect/translations/en.json +++ b/homeassistant/components/totalconnect/translations/en.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Auto bypass low battery" + }, + "description": "Automatically bypass zones the moment they report a low battery.", + "title": "TotalConnect Options" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/es.json b/homeassistant/components/totalconnect/translations/es.json index 7983aa3a11e..66f5be9ebc2 100644 --- a/homeassistant/components/totalconnect/translations/es.json +++ b/homeassistant/components/totalconnect/translations/es.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Ignorar autom\u00e1ticamente bater\u00eda baja" + }, + "description": "Ignorar autom\u00e1ticamente las zonas en el momento en que informan de que la bater\u00eda est\u00e1 baja.", + "title": "Opciones de TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/et.json b/homeassistant/components/totalconnect/translations/et.json index c4bca75a558..192476efa72 100644 --- a/homeassistant/components/totalconnect/translations/et.json +++ b/homeassistant/components/totalconnect/translations/et.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Aku t\u00fchjenemise automaatne eiramine" + }, + "description": "Eira tsoone automaatselt kui nad teatavad t\u00fchjast akust.", + "title": "TotalConnecti valikud" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/fr.json b/homeassistant/components/totalconnect/translations/fr.json index fcda553a018..d06fe595890 100644 --- a/homeassistant/components/totalconnect/translations/fr.json +++ b/homeassistant/components/totalconnect/translations/fr.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Contournement automatique en cas de batterie faible" + }, + "description": "Contourner automatiquement les zones d\u00e8s qu'elles signalent une batterie faible.", + "title": "Options TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index 3bb2b4136c9..0b62895ddcd 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Automatikus kiiktat\u00e1s alacsony akkumul\u00e1torral" + }, + "description": "Automatikusan kiiktatja a z\u00f3n\u00e1kat abban a pillanatban, amikor lemer\u00fclt akkumul\u00e1tort jelentenek.", + "title": "TotalConnect be\u00e1ll\u00edt\u00e1sok" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/id.json b/homeassistant/components/totalconnect/translations/id.json index 1702ceb5688..b8fc7cdd7df 100644 --- a/homeassistant/components/totalconnect/translations/id.json +++ b/homeassistant/components/totalconnect/translations/id.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Otomatis dilewatkan saat baterai lemah" + }, + "description": "Otomatis melewatkan zona saat baterai lemah dilaporkan.", + "title": "Opsi TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/it.json b/homeassistant/components/totalconnect/translations/it.json index 32f0815d380..dc5c1362e9c 100644 --- a/homeassistant/components/totalconnect/translations/it.json +++ b/homeassistant/components/totalconnect/translations/it.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Esclusione automatica della batteria scarica" + }, + "description": "Esclusione automatica delle zone nel momento in cui segnalano una batteria scarica.", + "title": "Opzioni TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/ja.json b/homeassistant/components/totalconnect/translations/ja.json index c20b55d5583..1e7750b2442 100644 --- a/homeassistant/components/totalconnect/translations/ja.json +++ b/homeassistant/components/totalconnect/translations/ja.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "\u30d0\u30c3\u30c6\u30ea\u30fc\u6b8b\u91cf(\u4f4e)\u3067\u306e\u81ea\u52d5\u30d0\u30a4\u30d1\u30b9" + }, + "description": "\u30d0\u30c3\u30c6\u30ea\u30fc\u6b8b\u91cf\u304c\u5c11\u306a\u3044\u3068\u5831\u544a\u3055\u308c\u305f\u77ac\u9593\u306b\u3001\u81ea\u52d5\u7684\u306b\u30be\u30fc\u30f3\u3092\u30d0\u30a4\u30d1\u30b9\u3057\u307e\u3059\u3002", + "title": "TotalConnect\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/nl.json b/homeassistant/components/totalconnect/translations/nl.json index cf9a6bb10a1..cec03dc035b 100644 --- a/homeassistant/components/totalconnect/translations/nl.json +++ b/homeassistant/components/totalconnect/translations/nl.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Automatische bypass van batterij bijna leeg" + }, + "description": "Automatisch zones omzeilen op het moment dat ze een bijna lege batterij melden.", + "title": "TotalConnect-opties" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/no.json b/homeassistant/components/totalconnect/translations/no.json index 86cfedf51d4..4ea6b791b23 100644 --- a/homeassistant/components/totalconnect/translations/no.json +++ b/homeassistant/components/totalconnect/translations/no.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Auto bypass lavt batteri" + }, + "description": "Omg\u00e5 automatisk soner i det \u00f8yeblikket de rapporterer lavt batteri.", + "title": "TotalConnect-alternativer" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/pl.json b/homeassistant/components/totalconnect/translations/pl.json index d3927212c82..07645e24ac5 100644 --- a/homeassistant/components/totalconnect/translations/pl.json +++ b/homeassistant/components/totalconnect/translations/pl.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Automatyczne obej\u015bcie niskiego poziomu baterii" + }, + "description": "Automatycznie pomijaj strefy, kt\u00f3re zg\u0142osz\u0105 niski poziom na\u0142adowania baterii.", + "title": "Opcje TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/pt-BR.json b/homeassistant/components/totalconnect/translations/pt-BR.json index 1ffeb1337ec..0ead8fba802 100644 --- a/homeassistant/components/totalconnect/translations/pt-BR.json +++ b/homeassistant/components/totalconnect/translations/pt-BR.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Ignoar autom\u00e1ticamente bateria fraca" + }, + "description": "Desative zonas automaticamente no momento em que relatam uma bateria fraca.", + "title": "Op\u00e7\u00f5es do TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/tr.json b/homeassistant/components/totalconnect/translations/tr.json index ef50457f846..f40eba3d429 100644 --- a/homeassistant/components/totalconnect/translations/tr.json +++ b/homeassistant/components/totalconnect/translations/tr.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "D\u00fc\u015f\u00fck pili otomatik atla" + }, + "description": "D\u00fc\u015f\u00fck pil bildirdikleri anda b\u00f6lgeleri otomatik olarak atlay\u0131n.", + "title": "Toplam Ba\u011flant\u0131 Se\u00e7enekleri" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/zh-Hant.json b/homeassistant/components/totalconnect/translations/zh-Hant.json index 3b960a8bc43..5e471fb1746 100644 --- a/homeassistant/components/totalconnect/translations/zh-Hant.json +++ b/homeassistant/components/totalconnect/translations/zh-Hant.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "\u81ea\u52d5\u5ffd\u7565\u4f4e\u96fb\u91cf" + }, + "description": "\u7576\u56de\u5831\u4f4e\u96fb\u91cf\u6642\u81ea\u52d5\u5ffd\u7565\u5340\u57df\u3002", + "title": "TotalConnect \u9078\u9805" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 9a419302a18..e9cc687cc02 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tplink", "requirements": ["python-kasa==0.5.0"], - "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco"], + "codeowners": ["@rytilahti", "@thegardenmonkey"], "dependencies": ["network"], "quality_scale": "platinum", "iot_class": "local_polling", diff --git a/homeassistant/components/tplink/translations/fr.json b/homeassistant/components/tplink/translations/fr.json index e1105ea00e0..75b15dbacef 100644 --- a/homeassistant/components/tplink/translations/fr.json +++ b/homeassistant/components/tplink/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name} {model} ( {host} )", "step": { "discovery_confirm": { - "description": "Voulez-vous configurer {name} {model} ( {host} )\u00a0?" + "description": "Voulez-vous configurer {name} {model} ({host})\u00a0?" }, "pick_device": { "data": { diff --git a/homeassistant/components/traccar/translations/es.json b/homeassistant/components/traccar/translations/es.json index 851984b0024..d23aa678816 100644 --- a/homeassistant/components/traccar/translations/es.json +++ b/homeassistant/components/traccar/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cloud_not_connected": "No est\u00e1 conectado a Home Assistant Cloud.", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py index 7ba6602a520..ba42aeb600d 100644 --- a/homeassistant/components/tractive/config_flow.py +++ b/homeassistant/components/tractive/config_flow.py @@ -1,6 +1,7 @@ """Config flow for tractive integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -69,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, _: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index 7f9737cf686..1f5d19118eb 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Trafikverket Ferry integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketFerry @@ -59,9 +60,7 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ferry_api = TrafikverketFerry(web_session, api_key) await ferry_api.async_get_next_ferry_stop(ferry_from, ferry_to) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index bab73d72210..256341a7132 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -55,21 +55,21 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_time"]), - info_fn=lambda data: data["departure_information"], + info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_from", name="Departure From", icon="mdi:ferry", - value_fn=lambda data: data["departure_from"], - info_fn=lambda data: data["departure_information"], + value_fn=lambda data: cast(str, data["departure_from"]), + info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_to", name="Departure To", icon="mdi:ferry", - value_fn=lambda data: data["departure_to"], - info_fn=lambda data: data["departure_information"], + value_fn=lambda data: cast(str, data["departure_to"]), + info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_modified", @@ -77,7 +77,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_modified"]), - info_fn=lambda data: data["departure_information"], + info_fn=lambda data: cast(list[str], data["departure_information"]), entity_registry_enabled_default=False, ), TrafikverketSensorEntityDescription( diff --git a/homeassistant/components/trafikverket_ferry/translations/es.json b/homeassistant/components/trafikverket_ferry/translations/es.json index 26532fbce5e..0ab89ac0fa8 100644 --- a/homeassistant/components/trafikverket_ferry/translations/es.json +++ b/homeassistant/components/trafikverket_ferry/translations/es.json @@ -1,10 +1,13 @@ { "config": { "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, "error": { + "cannot_connect": "Fallo en la conexi\u00f3n", "incorrect_api_key": "Clave API inv\u00e1lida para la cuenta seleccionada", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "invalid_route": "No se pudo encontrar la ruta con la informaci\u00f3n proporcionada" }, "step": { @@ -18,7 +21,8 @@ "api_key": "Clave API", "from": "Des del puerto", "time": "Hora", - "to": "Al puerto" + "to": "Al puerto", + "weekday": "D\u00edas entre semana" } } } diff --git a/homeassistant/components/trafikverket_ferry/translations/sv.json b/homeassistant/components/trafikverket_ferry/translations/sv.json new file mode 100644 index 00000000000..ad82dbe221d --- /dev/null +++ b/homeassistant/components/trafikverket_ferry/translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 4411ccab948..ee026371b04 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -1,15 +1,39 @@ """The trafikverket_train component.""" from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from pytrafikverket import TrafikverketTrain -from .const import PLATFORMS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_FROM, CONF_TO, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trafikverket Train from a config entry.""" + http_session = async_get_clientsession(hass) + train_api = TrafikverketTrain(http_session, entry.data[CONF_API_KEY]) + + try: + to_station = await train_api.async_get_train_station(entry.data[CONF_TO]) + from_station = await train_api.async_get_train_station(entry.data[CONF_FROM]) + except ValueError as error: + if "Invalid authentication" in error.args[0]: + raise ConfigEntryAuthFailed from error + raise ConfigEntryNotReady( + f"Problem when trying station {entry.data[CONF_FROM]} to {entry.data[CONF_TO]}. Error: {error} " + ) from error + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + CONF_TO: to_station, + CONF_FROM: from_station, + "train_api": train_api, + } + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 823b393f7b1..c620e264142 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Trafikverket Train integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketTrain @@ -54,9 +55,7 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await train_api.async_get_train_station(train_from) await train_api.async_get_train_station(train_to) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 4a419ff3b33..d9674e5373a 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -10,10 +10,8 @@ from pytrafikverket.trafikverket_train import StationInfo, TrainStop from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS +from homeassistant.const import CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -42,24 +40,11 @@ async def async_setup_entry( ) -> None: """Set up the Trafikverket sensor entry.""" - httpsession = async_get_clientsession(hass) - train_api = TrafikverketTrain(httpsession, entry.data[CONF_API_KEY]) - - try: - to_station = await train_api.async_get_train_station(entry.data[CONF_TO]) - from_station = await train_api.async_get_train_station(entry.data[CONF_FROM]) - except ValueError as error: - if "Invalid authentication" in error.args[0]: - raise ConfigEntryAuthFailed from error - raise ConfigEntryNotReady( - f"Problem when trying station {entry.data[CONF_FROM]} to {entry.data[CONF_TO]}. Error: {error} " - ) from error - - train_time = ( - dt.parse_time(entry.data.get(CONF_TIME, "")) - if entry.data.get(CONF_TIME) - else None - ) + train_api = hass.data[DOMAIN][entry.entry_id]["train_api"] + to_station = hass.data[DOMAIN][entry.entry_id][CONF_TO] + from_station = hass.data[DOMAIN][entry.entry_id][CONF_FROM] + get_time: str | None = entry.data.get(CONF_TIME) + train_time = dt.parse_time(get_time) if get_time else None async_add_entities( [ @@ -157,8 +142,8 @@ class TrainSensor(SensorEntity): _state = await self._train_api.async_get_next_train_stop( self._from_station, self._to_station, when ) - except ValueError as output_error: - _LOGGER.error("Departure %s encountered a problem: %s", when, output_error) + except ValueError as error: + _LOGGER.error("Departure %s encountered a problem: %s", when, error) if not _state: self._attr_available = False diff --git a/homeassistant/components/trafikverket_train/translations/es.json b/homeassistant/components/trafikverket_train/translations/es.json index 4ce1da04b02..2aca5e59745 100644 --- a/homeassistant/components/trafikverket_train/translations/es.json +++ b/homeassistant/components/trafikverket_train/translations/es.json @@ -1,15 +1,30 @@ { "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "incorrect_api_key": "Clave API inv\u00e1lida para la cuenta seleccionada", - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "invalid_station": "No se pudo encontrar una estaci\u00f3n con el nombre especificado", + "invalid_time": "Hora proporcionada no v\u00e1lida", + "more_stations": "Se encontraron varias estaciones con el nombre especificado" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Clave API" + } + }, "user": { "data": { + "api_key": "Clave API", "from": "Desde la estaci\u00f3n", - "time": "Hora (opcional)" + "time": "Hora (opcional)", + "to": "A la estaci\u00f3n", + "weekday": "D\u00edas" } } } diff --git a/homeassistant/components/trafikverket_train/translations/sv.json b/homeassistant/components/trafikverket_train/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/trafikverket_train/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/sv.json b/homeassistant/components/trafikverket_weatherstation/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index a824185b13e..ae5d2dacf61 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -8,7 +8,7 @@ import transmissionrpc from transmissionrpc.error import TransmissionError import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -20,11 +20,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DELETE_DATA, @@ -34,9 +33,7 @@ from .const import ( DATA_UPDATED, DEFAULT_DELETE_DATA, DEFAULT_LIMIT, - DEFAULT_NAME, DEFAULT_ORDER, - DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_DOWNLOADED_TORRENT, @@ -78,49 +75,17 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.Schema( } ) -TRANS_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) -) - -CONFIG_SCHEMA = vol.Schema( - vol.All(cv.deprecated(DOMAIN), {DOMAIN: vol.All(cv.ensure_list, [TRANS_SCHEMA])}), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Transmission Component from config.""" - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Transmission Component.""" client = TransmissionClient(hass, config_entry) hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = client - if not await client.async_setup(): - return False + await client.async_setup() return True @@ -186,15 +151,15 @@ class TransmissionClient: """Return the TransmissionData object.""" return self._tm_data - async def async_setup(self): + async def async_setup(self) -> None: """Set up the Transmission client.""" try: self.tm_api = await get_api(self.hass, self.config_entry.data) except CannotConnect as error: raise ConfigEntryNotReady from error - except (AuthenticationError, UnknownError): - return False + except (AuthenticationError, UnknownError) as error: + raise ConfigEntryAuthFailed from error self._tm_data = TransmissionData(self.hass, self.config_entry, self.tm_api) @@ -296,8 +261,6 @@ class TransmissionClient: self.config_entry.add_update_listener(self.async_options_updated) - return True - def add_options(self): """Add options for entry.""" if not self.config_entry.options: diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index d5c63aa736f..a21fe9b8837 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -1,4 +1,9 @@ """Config flow for Transmission Bittorent Client.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + import voluptuous as vol from homeassistant import config_entries @@ -11,6 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from . import get_api from .const import ( @@ -41,10 +47,13 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle Tansmission config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> TransmissionOptionsFlowHandler: """Get the options flow for this handler.""" return TransmissionOptionsFlowHandler(config_entry) @@ -83,18 +92,53 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config): - """Import from Transmission client config.""" - import_config[CONF_SCAN_INTERVAL] = import_config[ - CONF_SCAN_INTERVAL - ].total_seconds() - return await self.async_step_user(user_input=import_config) + 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} + try: + await get_api(self.hass, user_input) + + except AuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except (CannotConnect, UnknownError): + 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, + ) class TransmissionOptionsFlowHandler(config_entries.OptionsFlow): """Handle Transmission client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Transmission options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 81725ad7d16..0194917c416 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -10,6 +10,13 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } + }, + "reauth_confirm": { + "description": "The password for {username} is invalid.", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -18,7 +25,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "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%]" } }, "options": { diff --git a/homeassistant/components/transmission/translations/ca.json b/homeassistant/components/transmission/translations/ca.json index 4049cca3840..235e05bb78a 100644 --- a/homeassistant/components/transmission/translations/ca.json +++ b/homeassistant/components/transmission/translations/ca.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -9,6 +10,13 @@ "name_exists": "Nom ja existeix" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "La contrasenya de {username} \u00e9s inv\u00e0lida.", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/transmission/translations/de.json b/homeassistant/components/transmission/translations/de.json index 2355905d1f7..04274f2c1cb 100644 --- a/homeassistant/components/transmission/translations/de.json +++ b/homeassistant/components/transmission/translations/de.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -9,6 +10,13 @@ "name_exists": "Name existiert bereits" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Das Passwort f\u00fcr {username} ist ung\u00fcltig.", + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/el.json b/homeassistant/components/transmission/translations/el.json index 4a0701cdaf9..6790e6e351d 100644 --- a/homeassistant/components/transmission/translations/el.json +++ b/homeassistant/components/transmission/translations/el.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "reauth_successful": "\u039f \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" }, "error": { "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", @@ -9,6 +10,13 @@ "name_exists": "\u03a4\u03bf \u038c\u03bd\u03bf\u03bc\u03b1 \u03c5\u03c0\u03ac\u03c1\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2" + }, + "description": "\u039f \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03bf {username} \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2.", + "title": "\u0395\u03c0\u03b1\u03bd\u03b1\u03bb\u03b7\u03c0\u03c4\u03b9\u03ba\u03cc\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7\u03c2" + }, "user": { "data": { "host": "\u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2", diff --git a/homeassistant/components/transmission/translations/en.json b/homeassistant/components/transmission/translations/en.json index e92e307d3bc..3726f6f0a7e 100644 --- a/homeassistant/components/transmission/translations/en.json +++ b/homeassistant/components/transmission/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -9,6 +10,13 @@ "name_exists": "Name already exists" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "The password for {username} is invalid.", + "title": "Reauthenticate Integration" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/et.json b/homeassistant/components/transmission/translations/et.json index 1329444f7cf..745ef1030af 100644 --- a/homeassistant/components/transmission/translations/et.json +++ b/homeassistant/components/transmission/translations/et.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus", @@ -9,6 +10,13 @@ "name_exists": "Nimi on juba olemas" }, "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Kasutaja {username} salas\u00f5na on kehtetu", + "title": "Taastuvasta sidumine" + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/transmission/translations/fr.json b/homeassistant/components/transmission/translations/fr.json index f027b6909e8..539764ee64f 100644 --- a/homeassistant/components/transmission/translations/fr.json +++ b/homeassistant/components/transmission/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -9,6 +10,13 @@ "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9" }, "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe" + }, + "description": "Le mot de passe pour {username} n'est pas valide.", + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/transmission/translations/hu.json b/homeassistant/components/transmission/translations/hu.json index 79a60dc2b5b..1bd7129ed6b 100644 --- a/homeassistant/components/transmission/translations/hu.json +++ b/homeassistant/components/transmission/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -9,6 +10,13 @@ "name_exists": "A n\u00e9v m\u00e1r foglalt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "{username} jelszava \u00e9rv\u00e9nytelen.", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { "host": "C\u00edm", diff --git a/homeassistant/components/transmission/translations/id.json b/homeassistant/components/transmission/translations/id.json index a96524f5165..7b5fa3a703f 100644 --- a/homeassistant/components/transmission/translations/id.json +++ b/homeassistant/components/transmission/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -9,6 +10,13 @@ "name_exists": "Nama sudah ada" }, "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Kata sandi untuk {username} tidak valid.", + "title": "Autentikasi Ulang Integrasi" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/it.json b/homeassistant/components/transmission/translations/it.json index 18edfb17351..2cefcdfb290 100644 --- a/homeassistant/components/transmission/translations/it.json +++ b/homeassistant/components/transmission/translations/it.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", @@ -9,6 +10,13 @@ "name_exists": "Nome gi\u00e0 esistente" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "La password per {username} non \u00e8 valida.", + "title": "Autentica nuovamente l'integrazione" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/ja.json b/homeassistant/components/transmission/translations/ja.json index 5bd5b98a8b3..6f3b3191c83 100644 --- a/homeassistant/components/transmission/translations/ja.json +++ b/homeassistant/components/transmission/translations/ja.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" }, "error": { "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", @@ -9,6 +10,13 @@ "name_exists": "\u540d\u524d\u306f\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, "user": { "data": { "host": "\u30db\u30b9\u30c8", diff --git a/homeassistant/components/transmission/translations/nl.json b/homeassistant/components/transmission/translations/nl.json index fcc1e05e7ab..e80cda5ac8e 100644 --- a/homeassistant/components/transmission/translations/nl.json +++ b/homeassistant/components/transmission/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie geslaagd" }, "error": { "cannot_connect": "Kan geen verbinding maken", @@ -9,6 +10,13 @@ "name_exists": "Naam bestaat al" }, "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Het wachtwoord voor {username} is onjuist.", + "title": "Integratie herauthenticeren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/no.json b/homeassistant/components/transmission/translations/no.json index cab8fd22659..fe15e4adc43 100644 --- a/homeassistant/components/transmission/translations/no.json +++ b/homeassistant/components/transmission/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -9,6 +10,13 @@ "name_exists": "Navn eksisterer allerede" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Passordet for {username} er ugyldig.", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/transmission/translations/pl.json b/homeassistant/components/transmission/translations/pl.json index dd2b28ad65f..994744a3547 100644 --- a/homeassistant/components/transmission/translations/pl.json +++ b/homeassistant/components/transmission/translations/pl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", @@ -9,6 +10,13 @@ "name_exists": "Nazwa ju\u017c istnieje" }, "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o" + }, + "description": "Has\u0142o dla u\u017cytkownika {username} jest nieprawid\u0142owe.", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", diff --git a/homeassistant/components/transmission/translations/pt-BR.json b/homeassistant/components/transmission/translations/pt-BR.json index 3353884ef33..781d21e2900 100644 --- a/homeassistant/components/transmission/translations/pt-BR.json +++ b/homeassistant/components/transmission/translations/pt-BR.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "reauth_successful": "Re-autenticado com sucesso" }, "error": { "cannot_connect": "Falha ao conectar", @@ -9,6 +10,13 @@ "name_exists": "O Nome j\u00e1 existe" }, "step": { + "reauth_confirm": { + "data": { + "password": "Senha" + }, + "description": "A senha para o usu\u00e1rio {username} est\u00e1 inv\u00e1lida.", + "title": "Re-autenticar integra\u00e7\u00e3o" + }, "user": { "data": { "host": "Nome do host", diff --git a/homeassistant/components/transmission/translations/sv.json b/homeassistant/components/transmission/translations/sv.json index 848fa71de60..0ffbebed9f6 100644 --- a/homeassistant/components/transmission/translations/sv.json +++ b/homeassistant/components/transmission/translations/sv.json @@ -1,13 +1,20 @@ { "config": { "abort": { - "already_configured": "V\u00e4rden \u00e4r redan konfigurerad." + "already_configured": "V\u00e4rden \u00e4r redan konfigurerad.", + "reauth_successful": "\u00c5terautentisering lyckades" }, "error": { "cannot_connect": "Det g\u00e5r inte att ansluta till v\u00e4rden", "name_exists": "Namnet finns redan" }, "step": { + "reauth_confirm": { + "data": { + "password": "L\u00f6senord" + }, + "title": "\u00c5terautenticera integration" + }, "user": { "data": { "host": "V\u00e4rd", diff --git a/homeassistant/components/transmission/translations/tr.json b/homeassistant/components/transmission/translations/tr.json index 72b3410062e..bef2fd47ff7 100644 --- a/homeassistant/components/transmission/translations/tr.json +++ b/homeassistant/components/transmission/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -9,6 +10,13 @@ "name_exists": "Ad zaten var" }, "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "{username} i\u00e7n \u015fifre ge\u00e7ersiz.", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, "user": { "data": { "host": "Sunucu", diff --git a/homeassistant/components/transmission/translations/uk.json b/homeassistant/components/transmission/translations/uk.json index 5bc74f7da2a..9fbe0848657 100644 --- a/homeassistant/components/transmission/translations/uk.json +++ b/homeassistant/components/transmission/translations/uk.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u0426\u0435\u0439 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant." + "already_configured": "\u0426\u0435\u0439 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f \u043f\u0440\u043e\u0439\u0448\u043b\u0430 \u0443\u0441\u043f\u0456\u0448\u043d\u043e" }, "error": { "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", @@ -9,6 +10,13 @@ "name_exists": "\u0426\u044f \u043d\u0430\u0437\u0432\u0430 \u0432\u0436\u0435 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f." }, "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f {username} \u043d\u0435\u0434\u0456\u0439\u0441\u043d\u0438\u0439.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u0457" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/transmission/translations/zh-Hant.json b/homeassistant/components/transmission/translations/zh-Hant.json index b6769274148..fd3d3a909aa 100644 --- a/homeassistant/components/transmission/translations/zh-Hant.json +++ b/homeassistant/components/transmission/translations/zh-Hant.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", @@ -9,6 +10,13 @@ "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "{username} \u5bc6\u78bc\u7121\u6548\u3002", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index aaae8f7cc54..b579cc036bb 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.21.6"], + "requirements": ["numpy==1.23.0"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index aca09131b4c..aae50902d03 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -116,7 +116,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if not (status := self.device.status.get(self.entity_description.key)): return None diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 3b4a2883266..26014e53b74 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,8 +1,6 @@ """Support for Tuya buttons.""" from __future__ import annotations -from typing import Any - from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -109,6 +107,6 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity): self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" - def press(self, **kwargs: Any) -> None: + def press(self) -> None: """Press the button.""" self._send_command([{"code": self.entity_description.key, "value": True}]) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index e7340040658..727e505200b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -181,6 +181,7 @@ class DPCode(StrEnum): CLEAN_AREA = "clean_area" CLEAN_TIME = "clean_time" CLICK_SUSTAIN_TIME = "click_sustain_time" + CLOUD_RECIPE_NUMBER = "cloud_recipe_number" CO_STATE = "co_state" CO_STATUS = "co_status" CO_VALUE = "co_value" @@ -191,6 +192,8 @@ class DPCode(StrEnum): COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode + COOK_TEMPERATURE = "cook_temperature" + COOK_TIME = "cook_time" CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" CONTROL_2 = "control_2" @@ -223,6 +226,7 @@ class DPCode(StrEnum): FAN_SPEED = "fan_speed" FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed + FAN_SWITCH = "fan_switch" FAN_MODE = "fan_mode" FAN_VERTICAL = "fan_vertical" # Vertical swing flap angle FAR_DETECTION = "far_detection" @@ -294,6 +298,7 @@ class DPCode(StrEnum): RECORD_MODE = "record_mode" RECORD_SWITCH = "record_switch" # Recording switch RELAY_STATUS = "relay_status" + REMAIN_TIME = "remain_time" RESET_DUSTER_CLOTH = "reset_duster_cloth" RESET_EDGE_BRUSH = "reset_edge_brush" RESET_FILTER = "reset_filter" @@ -574,7 +579,7 @@ UNITS = ( ), UnitOfMeasurement( unit=TEMP_CELSIUS, - aliases={"°c", "c", "celsius"}, + aliases={"°c", "c", "celsius", "℃"}, device_classes={SensorDeviceClass.TEMPERATURE}, ), UnitOfMeasurement( diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index e75b07b988d..d8b0a97480e 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -360,7 +360,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): ] ) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" if self._tilt is None: raise RuntimeError( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 2d16ed36d40..021745f4c81 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -74,7 +74,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity): super().__init__(device, device_manager) self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.SWITCH), prefer_function=True + (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True ) self._attr_preset_modes = [] @@ -158,8 +158,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): def turn_on( self, - percentage: int = None, - preset_mode: str = None, + percentage: int | None = None, + preset_mode: str | None = None, **kwargs: Any, ) -> None: """Turn on the fan.""" @@ -177,7 +177,6 @@ class TuyaFanEntity(TuyaEntity, FanEntity): "value": int(self._speed.remap_value_from(percentage, 1, 100)), } ) - return if percentage is not None and self._speeds is not None: commands.append( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 35efd78871f..e7712dcf630 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -3,8 +3,13 @@ from __future__ import annotations from tuya_iot import TuyaDevice, TuyaDeviceManager -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 TIME_MINUTES from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory @@ -12,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData from .base import IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. @@ -33,24 +38,28 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP_SET, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_SET_F, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_C, name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_F, name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), @@ -108,6 +117,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP_SET, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), @@ -123,6 +133,28 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + NumberEntityDescription( + key=DPCode.COOK_TEMPERATURE, + name="Cook Temperature", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COOK_TIME, + name="Cook Time", + icon="mdi:timer", + native_unit_of_measurement=TIME_MINUTES, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.CLOUD_RECIPE_NUMBER, + name="Cloud Recipe", + entity_category=EntityCategory.CONFIG, + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -256,6 +288,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), ), @@ -265,11 +298,13 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP_SET, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), NumberEntityDescription( key=DPCode.TEMP_SET_F, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), ), @@ -326,14 +361,49 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): description.key, dptype=DPType.INTEGER, prefer_function=True ): self._number = int_type - self._attr_max_value = self._number.max_scaled - self._attr_min_value = self._number.min_scaled - self._attr_step = self._number.step_scaled - if description.unit_of_measurement is None: - self._attr_unit_of_measurement = self._number.unit + self._attr_native_max_value = self._number.max_scaled + self._attr_native_min_value = self._number.min_scaled + self._attr_native_step = self._number.step_scaled + + # Logic to ensure the set device class and API received Unit Of Measurement + # match Home Assistants requirements. + if ( + self.device_class is not None + and not self.device_class.startswith(DOMAIN) + and description.native_unit_of_measurement is None + ): + + # We cannot have a device class, if the UOM isn't set or the + # device class cannot be found in the validation mapping. + if ( + self.native_unit_of_measurement is None + or self.device_class not in DEVICE_CLASS_UNITS + ): + self._attr_device_class = None + return + + uoms = DEVICE_CLASS_UNITS[self.device_class] + self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + self.native_unit_of_measurement.lower() + ) + + # Unknown unit of measurement, device class should not be used. + if self._uom is None: + self._attr_device_class = None + return + + # If we still have a device class, we should not use an icon + if self.device_class: + self._attr_icon = None + + # Found unit of measurement, use the standardized Unit + # Use the target conversion unit (if set) + self._attr_native_unit_of_measurement = ( + self._uom.conversion_unit or self._uom.unit + ) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" # Unknown or unsupported data type if self._number is None: @@ -345,7 +415,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): return self._number.scale_value(value) - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Set new value.""" if self._number is None: raise RuntimeError("Cannot set value, device doesn't provide type data") diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index acb2ffe7987..dd2996f61ba 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, PERCENTAGE, POWER_KILO_WATT, + TIME_MINUTES, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -379,6 +380,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { # Door Window Sensor # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m "mcs": BATTERY_SENSORS, + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + name="Current Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.STATUS, + name="Status", + device_class=TuyaDeviceClass.STATUS, + ), + TuyaSensorEntityDescription( + key=DPCode.REMAIN_TIME, + name="Remaining Time", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:timer", + ), + ), # PIR Detector # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 "pir": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index d587e8ea54b..37834f0f273 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -269,6 +269,16 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Switch", + icon="mdi:pot-steam", + entity_category=EntityCategory.CONFIG, + ), + ), # Power Socket # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s "pc": ( diff --git a/homeassistant/components/tuya/translations/select.es.json b/homeassistant/components/tuya/translations/select.es.json index e29ccdc381f..b6ab1f8ce1b 100644 --- a/homeassistant/components/tuya/translations/select.es.json +++ b/homeassistant/components/tuya/translations/select.es.json @@ -23,12 +23,17 @@ "morning": "Ma\u00f1ana", "night": "Noche" }, + "tuya__curtain_motor_mode": { + "back": "Volver", + "forward": "Adelante" + }, "tuya__decibel_sensitivity": { "0": "Sensibilidad baja", "1": "Sensibilidad alta" }, "tuya__fan_angle": { "30": "30\u00b0", + "60": "60\u00b0", "90": "90\u00b0" }, "tuya__fingerbot_mode": { @@ -48,8 +53,11 @@ "level_9": "Nivel 9" }, "tuya__humidifier_moodlighting": { + "1": "Estado de \u00e1nimo 1", + "2": "Estado de \u00e1nimo 2", "3": "Estado 3", - "4": "Estado 4" + "4": "Estado 4", + "5": "Estado de \u00e1nimo 5" }, "tuya__humidifier_spray_mode": { "auto": "Autom\u00e1tico", diff --git a/homeassistant/components/tuya/translations/select.sv.json b/homeassistant/components/tuya/translations/select.sv.json new file mode 100644 index 00000000000..05092fe808c --- /dev/null +++ b/homeassistant/components/tuya/translations/select.sv.json @@ -0,0 +1,17 @@ +{ + "state": { + "tuya__humidifier_spray_mode": { + "humidity": "Luftfuktighet" + }, + "tuya__light_mode": { + "none": "Av" + }, + "tuya__relay_status": { + "on": "P\u00e5", + "power_on": "P\u00e5" + }, + "tuya__vacuum_mode": { + "standby": "Standby" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/translations/bg.json b/homeassistant/components/twentemilieu/translations/bg.json index 6ddc25a367e..7aa70f6d7f5 100644 --- a/homeassistant/components/twentemilieu/translations/bg.json +++ b/homeassistant/components/twentemilieu/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "invalid_address": "\u0410\u0434\u0440\u0435\u0441\u044a\u0442 \u043d\u0435 \u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d \u0432 \u0437\u043e\u043d\u0430 \u0437\u0430 \u043e\u0431\u0441\u043b\u0443\u0436\u0432\u0430\u043d\u0435 \u043d\u0430 Twente Milieu." }, "step": { diff --git a/homeassistant/components/twinkly/translations/fr.json b/homeassistant/components/twinkly/translations/fr.json index cf6fe97ce88..d076be1f399 100644 --- a/homeassistant/components/twinkly/translations/fr.json +++ b/homeassistant/components/twinkly/translations/fr.json @@ -8,7 +8,7 @@ }, "step": { "discovery_confirm": { - "description": "Voulez-vous configurer {name} - {model} ( {host} )\u00a0?" + "description": "Voulez-vous configurer {name} - {model} ({host})\u00a0?" }, "user": { "data": { diff --git a/homeassistant/components/ukraine_alarm/translations/es.json b/homeassistant/components/ukraine_alarm/translations/es.json index a9efcd0d536..3e1b8f322bb 100644 --- a/homeassistant/components/ukraine_alarm/translations/es.json +++ b/homeassistant/components/ukraine_alarm/translations/es.json @@ -1,12 +1,20 @@ { "config": { "abort": { + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada", "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "max_regions": "Se pueden configurar un m\u00e1ximo de 5 regiones", + "rate_limit": "Demasiadas peticiones", "timeout": "Tiempo m\u00e1ximo de espera para establecer la conexi\u00f3n agotado", "unknown": "Error inesperado" }, "step": { + "community": { + "data": { + "region": "Regi\u00f3n" + }, + "description": "Si desea monitorear no solo el estado y el distrito, elija su comunidad espec\u00edfica" + }, "district": { "data": { "region": "Regi\u00f3n" diff --git a/homeassistant/components/ukraine_alarm/translations/pt-BR.json b/homeassistant/components/ukraine_alarm/translations/pt-BR.json index 64b371196e3..128bd85492d 100644 --- a/homeassistant/components/ukraine_alarm/translations/pt-BR.json +++ b/homeassistant/components/ukraine_alarm/translations/pt-BR.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "O local j\u00e1 est\u00e1 configurado", - "cannot_connect": "Falhou ao conectar", + "already_configured": "Localiza\u00e7\u00e3o j\u00e1 est\u00e1 configurada", + "cannot_connect": "Falha ao conectar", "max_regions": "M\u00e1ximo de 5 regi\u00f5es podem ser configuradas", "rate_limit": "Excesso de pedidos", - "timeout": "Tempo limite estabelecendo conex\u00e3o", + "timeout": "Tempo limite para estabelecer conex\u00e3o atingido", "unknown": "Erro inesperado" }, "step": { diff --git a/homeassistant/components/ukraine_alarm/translations/sv.json b/homeassistant/components/ukraine_alarm/translations/sv.json new file mode 100644 index 00000000000..4a9945525c8 --- /dev/null +++ b/homeassistant/components/ukraine_alarm/translations/sv.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "cannot_connect": "Det gick inte att ansluta.", + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 50e578b1dae..fcf5970bf6c 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -202,7 +202,7 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index d481f0d0fc4..36186b6fed8 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Network", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==32"], + "requirements": ["aiounifi==34"], "codeowners": ["@Kane610"], "quality_scale": "platinum", "ssdp": [ diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index c28f2639e00..40214b60766 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -7,7 +7,8 @@ import logging from aiohttp import CookieJar from aiohttp.client_exceptions import ServerDisconnectedError -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient +from pyunifiprotect import ProtectApiClient +from pyunifiprotect.exceptions import ClientError, NotAuthorized from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,11 +18,10 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, - Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -35,69 +35,17 @@ from .const import ( OUTDATED_LOG_MESSAGE, PLATFORMS, ) -from .data import ProtectData +from .data import ProtectData, async_ufp_instance_for_config_entry_ids from .discovery import async_start_discovery +from .migrate import async_migrate_data from .services import async_cleanup_services, async_setup_services +from .utils import _async_unifi_mac_from_hass, async_get_devices _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) -async def _async_migrate_data( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient -) -> None: - - registry = er.async_get(hass) - to_migrate = [] - for entity in er.async_entries_for_config_entry(registry, entry.entry_id): - if entity.domain == Platform.BUTTON and "_" not in entity.unique_id: - _LOGGER.debug("Button %s needs migration", entity.entity_id) - to_migrate.append(entity) - - if len(to_migrate) == 0: - _LOGGER.debug("No entities need migration") - return - - _LOGGER.info("Migrating %s reboot button entities ", len(to_migrate)) - bootstrap = await protect.get_bootstrap() - count = 0 - for button in to_migrate: - device = None - for model in DEVICES_THAT_ADOPT: - attr = f"{model.value}s" - device = getattr(bootstrap, attr).get(button.unique_id) - if device is not None: - break - - if device is None: - continue - - new_unique_id = f"{device.id}_reboot" - _LOGGER.debug( - "Migrating entity %s (old unique_id: %s, new unique_id: %s)", - button.entity_id, - button.unique_id, - new_unique_id, - ) - try: - registry.async_update_entity(button.entity_id, new_unique_id=new_unique_id) - except ValueError: - _LOGGER.warning( - "Could not migrate entity %s (old unique_id: %s, new unique_id: %s)", - button.entity_id, - button.unique_id, - new_unique_id, - ) - else: - count += 1 - - if count < len(to_migrate): - _LOGGER.warning("Failed to migate %s reboot buttons", len(to_migrate) - count) - else: - _LOGGER.info("Migrated %s reboot button entities", count) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" @@ -113,6 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: subscribed_models=DEVICES_FOR_SUBSCRIBE, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), + ignore_unadopted=False, ) _LOGGER.debug("Connect to UniFi Protect") data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) @@ -121,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nvr_info = await protect.get_nvr() except NotAuthorized as err: raise ConfigEntryAuthFailed(err) from err - except (asyncio.TimeoutError, NvrError, ServerDisconnectedError) as err: + except (asyncio.TimeoutError, ClientError, ServerDisconnectedError) as err: raise ConfigEntryNotReady from err if nvr_info.version < MIN_REQUIRED_PROTECT_V: @@ -132,7 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - await _async_migrate_data(hass, entry, protect) + await async_migrate_data(hass, entry, protect) if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) @@ -166,3 +115,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_cleanup_services(hass) return bool(unload_ok) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove ufp config entry from a device.""" + unifi_macs = { + _async_unifi_mac_from_hass(connection[1]) + for connection in device_entry.connections + if connection[0] == dr.CONNECTION_NETWORK_MAC + } + api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id}) + assert api is not None + if api.bootstrap.nvr.mac in unifi_macs: + return False + for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT): + if device.is_adopted_by_us and device.mac in unifi_macs: + return False + return True diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 7613ffb8ebf..62a4893692b 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -5,7 +5,17 @@ from copy import copy from dataclasses import dataclass import logging -from pyunifiprotect.data import NVR, Camera, Event, Light, MountType, Sensor +from pyunifiprotect.data import ( + NVR, + Camera, + Event, + Light, + MountType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, + Sensor, +) +from pyunifiprotect.data.nvr import UOSDisk from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -13,12 +23,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODEL from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ( EventThumbnailMixin, @@ -26,7 +36,8 @@ from .entity import ( ProtectNVREntity, async_all_device_entities, ) -from .models import ProtectRequiredKeysMixin +from .models import PermRequired, ProtectRequiredKeysMixin +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" @@ -61,6 +72,126 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( icon="mdi:brightness-6", ufp_value="is_dark", ), + ProtectBinaryEntityDescription( + key="ssh", + name="SSH Enabled", + icon="mdi:lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_ssh_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_led_status", + ufp_value="led_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="hdr_mode", + name="HDR Mode", + icon="mdi:brightness-7", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_hdr", + ufp_value="hdr_mode", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="high_fps", + name="High FPS", + icon="mdi:video-high-definition", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_highfps", + ufp_value="is_high_fps_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="system_sounds", + name="System Sounds", + icon="mdi:speaker", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_speaker", + ufp_value="speaker_settings.are_system_sounds_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_name", + name="Overlay: Show Name", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_name_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_date", + name="Overlay: Show Date", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_date_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_logo", + name="Overlay: Show Logo", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_logo_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_bitrate", + name="Overlay: Show Bitrate", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_debug_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="motion_enabled", + name="Detections: Motion", + icon="mdi:run-fast", + ufp_value="recording_settings.enable_motion_detection", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_person", + name="Detections: Person", + icon="mdi:walk", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_person", + ufp_value="is_person_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_vehicle", + name="Detections: Vehicle", + icon="mdi:car", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_vehicle", + ufp_value="is_vehicle_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_face", + name="Detections: Face", + icon="mdi:human-greeting", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_face", + ufp_value="is_face_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_package", + name="Detections: Package", + icon="mdi:package-variant-closed", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_package", + ufp_value="is_package_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), ) LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -76,6 +207,31 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_pir_motion_detected", ), + ProtectBinaryEntityDescription( + key="light", + name="Flood Light", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_light_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="ssh", + name="SSH Enabled", + icon="mdi:lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_ssh_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="light_device_settings.is_indicator_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), ) SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -106,6 +262,53 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.TAMPER, ufp_value="is_tampering_detected", ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="led_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="motion_enabled", + name="Motion Detection", + icon="mdi:walk", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="motion_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="temperature", + name="Temperature Sensor", + icon="mdi:thermometer", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="temperature_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="humidity", + name="Humidity Sensor", + icon="mdi:water-percent", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="humidity_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="light", + name="Light Sensor", + icon="mdi:brightness-5", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="light_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="alarm", + name="Alarm Sound Detection", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="alarm_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), ) MOTION_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -125,13 +328,32 @@ DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ufp_value="battery_status.is_low", ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="led_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), +) + +VIEWER_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( + ProtectBinaryEntityDescription( + key="ssh", + name="SSH Enabled", + icon="mdi:lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_ssh_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), ) DISK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="disk_health", - name="Disk {index} Health", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -145,6 +367,26 @@ async def async_setup_entry( ) -> None: """Set up binary sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities: list[ProtectDeviceEntity] = async_all_device_entities( + data, + ProtectDeviceBinarySensor, + camera_descs=CAMERA_SENSORS, + light_descs=LIGHT_SENSORS, + sense_descs=SENSE_SENSORS, + lock_descs=DOORLOCK_SENSORS, + viewer_descs=VIEWER_SENSORS, + ufp_device=device, + ) + if device.is_adopted and isinstance(device, Camera): + entities += _async_motion_entities(data, ufp_device=device) + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectDeviceBinarySensor, @@ -152,6 +394,7 @@ async def async_setup_entry( light_descs=LIGHT_SENSORS, sense_descs=SENSE_SENSORS, lock_descs=DOORLOCK_SENSORS, + viewer_descs=VIEWER_SENSORS, ) entities += _async_motion_entities(data) entities += _async_nvr_entities(data) @@ -162,15 +405,22 @@ async def async_setup_entry( @callback def _async_motion_entities( data: ProtectData, + ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] - for device in data.api.bootstrap.cameras.values(): + devices = ( + data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device] + ) + for device in devices: + if not device.is_adopted: + continue + for description in MOTION_SENSORS: entities.append(ProtectEventBinarySensor(data, device, description)) _LOGGER.debug( "Adding binary sensor entity %s for %s", description.name, - device.name, + device.display_name, ) return entities @@ -182,14 +432,18 @@ def _async_nvr_entities( ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] device = data.api.bootstrap.nvr - for index, _ in enumerate(device.system_info.storage.devices): + if device.system_info.ustorage is None: + return entities + + for disk in device.system_info.ustorage.disks: for description in DISK_SENSORS: - entities.append( - ProtectDiskBinarySensor(data, device, description, index=index) - ) + if not disk.has_disk: + continue + + entities.append(ProtectDiskBinarySensor(data, device, description, disk)) _LOGGER.debug( "Adding binary sensor entity %s", - (description.name or "{index}").format(index=index), + f"{disk.type} {disk.slot}", ) return entities @@ -202,8 +456,8 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): entity_description: ProtectBinaryEntityDescription @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + 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) # UP Sense can be any of the 3 contact sensor device classes @@ -216,6 +470,7 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): """A UniFi Protect NVR Disk Binary Sensor.""" + _disk: UOSDisk entity_description: ProtectBinaryEntityDescription def __init__( @@ -223,26 +478,35 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): data: ProtectData, device: NVR, description: ProtectBinaryEntityDescription, - index: int, + disk: UOSDisk, ) -> None: """Initialize the Binary Sensor.""" + self._disk = disk + # backwards compat with old unique IDs + index = self._disk.slot - 1 + description = copy(description) description.key = f"{description.key}_{index}" - description.name = (description.name or "{index}").format(index=index) - self._index = index + description.name = f"{disk.type} {disk.slot}" super().__init__(data, device, description) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) - disks = self.device.system_info.storage.devices - disk_available = len(disks) > self._index - self._attr_available = self._attr_available and disk_available - if disk_available: - disk = disks[self._index] - self._attr_is_on = not disk.healthy - self._attr_extra_state_attributes = {ATTR_MODEL: disk.model} + slot = self._disk.slot + self._attr_available = False + + # should not be possible since it would require user to + # _downgrade_ to make ustorage disppear + assert self.device.system_info.ustorage is not None + for disk in self.device.system_info.ustorage.disks: + if disk.slot == slot: + self._disk = disk + self._attr_available = True + break + + self._attr_is_on = not self._disk.is_healthy class ProtectEventBinarySensor(EventThumbnailMixin, ProtectDeviceBinarySensor): diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 3728b6b4224..9440e46b936 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -13,12 +13,14 @@ from homeassistant.components.button import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_dispatch_id as _ufpd @dataclass @@ -40,6 +42,17 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, name="Reboot Device", ufp_press="reboot", + ufp_perm=PermRequired.WRITE, + ), +) + +SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( + ProtectButtonEntityDescription( + key="clear_tamper", + name="Clear Tamper", + icon="mdi:notification-clear-all", + ufp_press="clear_tamper", + ufp_perm=PermRequired.WRITE, ), ) @@ -68,10 +81,28 @@ async def async_setup_entry( """Discover devices on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, ProtectButton, all_descs=ALL_DEVICE_BUTTONS, chime_descs=CHIME_BUTTONS + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectButton, + all_descs=ALL_DEVICE_BUTTONS, + chime_descs=CHIME_BUTTONS, + sense_descs=SENSOR_BUTTONS, + ufp_device=device, + ) + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) ) + entities: list[ProtectDeviceEntity] = async_all_device_entities( + data, + ProtectButton, + all_descs=ALL_DEVICE_BUTTONS, + chime_descs=CHIME_BUTTONS, + sense_descs=SENSOR_BUTTONS, + ) async_add_entities(entities) @@ -88,7 +119,7 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): ) -> None: """Initialize an UniFi camera.""" super().__init__(data, device, description) - self._attr_name = f"{self.device.name} {self.entity_description.name}" + self._attr_name = f"{self.device.display_name} {self.entity_description.name}" async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index ca076e490a2..76bfc72408d 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -4,12 +4,18 @@ from __future__ import annotations from collections.abc import Generator import logging -from pyunifiprotect.api import ProtectApiClient -from pyunifiprotect.data import Camera as UFPCamera, CameraChannel, StateType +from pyunifiprotect.data import ( + Camera as UFPCamera, + CameraChannel, + ProtectAdoptableDeviceModel, + ProtectModelWithId, + StateType, +) from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -18,23 +24,39 @@ from .const import ( ATTR_FPS, ATTR_HEIGHT, ATTR_WIDTH, + DISPATCH_ADOPT, + DISPATCH_CHANNELS, DOMAIN, ) from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) def get_camera_channels( - protect: ProtectApiClient, + data: ProtectData, + ufp_device: UFPCamera | None = None, ) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]: """Get all the camera channels.""" - for camera in protect.bootstrap.cameras.values(): + + devices = ( + data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device] + ) + for camera in devices: + if not camera.is_adopted_by_us: + continue + if not camera.channels: - _LOGGER.warning( - "Camera does not have any channels: %s (id: %s)", camera.name, camera.id - ) + if ufp_device is None: + # only warn on startup + _LOGGER.warning( + "Camera does not have any channels: %s (id: %s)", + camera.display_name, + camera.id, + ) + data.async_add_pending_camera_id(camera.id) continue is_default = True @@ -50,17 +72,12 @@ def get_camera_channels( yield camera, camera.channels[0], True -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Discover cameras on a UniFi Protect NVR.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] +def _async_camera_entities( + data: ProtectData, ufp_device: UFPCamera | None = None +) -> list[ProtectDeviceEntity]: disable_stream = data.disable_stream - - entities = [] - for camera, channel, is_default in get_camera_channels(data.api): + entities: list[ProtectDeviceEntity] = [] + for camera, channel, is_default in get_camera_channels(data, ufp_device): # do not enable streaming for package camera # 2 FPS causes a lot of buferring entities.append( @@ -85,6 +102,32 @@ async def async_setup_entry( disable_stream, ) ) + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Discover cameras on a UniFi Protect NVR.""" + data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not isinstance(device, UFPCamera): + return + + entities = _async_camera_entities(data, ufp_device=device) + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_CHANNELS), _add_new_device) + ) + + entities = _async_camera_entities(data) async_add_entities(entities) @@ -110,11 +153,11 @@ class ProtectCamera(ProtectDeviceEntity, Camera): super().__init__(data, camera) if self._secure: - self._attr_unique_id = f"{self.device.id}_{self.channel.id}" - self._attr_name = f"{self.device.name} {self.channel.name}" + self._attr_unique_id = f"{self.device.mac}_{self.channel.id}" + self._attr_name = f"{self.device.display_name} {self.channel.name}" else: - self._attr_unique_id = f"{self.device.id}_{self.channel.id}_insecure" - self._attr_name = f"{self.device.name} {self.channel.name} Insecure" + self._attr_unique_id = f"{self.device.mac}_{self.channel.id}_insecure" + self._attr_name = f"{self.device.display_name} {self.channel.name} Insecure" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure @@ -137,12 +180,12 @@ class ProtectCamera(ProtectDeviceEntity, Camera): ) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self.channel = self.device.channels[self.channel.id] + motion_enabled = self.device.recording_settings.enable_motion_detection self._attr_motion_detection_enabled = ( - self.device.state == StateType.CONNECTED - and self.device.feature_flags.has_motion_zones + motion_enabled if motion_enabled is not None else True ) self._attr_is_recording = ( self.device.state == StateType.CONNECTED and self.device.is_recording @@ -171,3 +214,11 @@ class ProtectCamera(ProtectDeviceEntity, Camera): async def stream_source(self) -> str | None: """Return the Stream Source.""" return self._stream_source + + async def async_enable_motion_detection(self) -> None: + """Call the job and enable motion detection.""" + await self.device.set_motion_detection(True) + + async def async_disable_motion_detection(self) -> None: + """Call the job and disable motion detection.""" + await self.device.set_motion_detection(False) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index d9fd3a87cb0..330a5e530a1 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -1,12 +1,14 @@ """Config Flow to configure UniFi Protect Integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from aiohttp import CookieJar -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient +from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import NVR +from pyunifiprotect.exceptions import ClientError, NotAuthorized from unifi_discovery import async_console_is_alive import voluptuous as vol @@ -173,9 +175,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_VERIFY_SSL] = False nvr_data, errors = await self._async_get_nvr_data(user_input) if nvr_data and not errors: - return self._async_create_entry( - nvr_data.name or nvr_data.type, user_input - ) + return self._async_create_entry(nvr_data.display_name, user_input) placeholders = { "name": discovery_info["hostname"] @@ -252,7 +252,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except NotAuthorized as ex: _LOGGER.debug(ex) errors[CONF_PASSWORD] = "invalid_auth" - except NvrError as ex: + except ClientError as ex: _LOGGER.debug(ex) errors["base"] = "cannot_connect" else: @@ -266,7 +266,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return nvr_data, errors - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) @@ -321,9 +321,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(nvr_data.mac) self._abort_if_unique_id_configured() - return self._async_create_entry( - nvr_data.name or nvr_data.type, user_input - ) + return self._async_create_entry(nvr_data.display_name, user_input) user_input = user_input or {} return self.async_show_form( diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 3ba22e6b85b..3c29d0c9972 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -60,3 +60,6 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, ] + +DISPATCH_ADOPT = "adopt_device" +DISPATCH_CHANNELS = "new_camera_channels" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index b902409595e..9e0783a99b1 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -1,28 +1,44 @@ """Base class for protect data.""" from __future__ import annotations -from collections.abc import Generator, Iterable +from collections.abc import Callable, Generator, Iterable from datetime import timedelta import logging -from typing import Any +from typing import Any, Union -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient +from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( + NVR, Bootstrap, Event, + EventType, Liveview, ModelType, + ProtectAdoptableDeviceModel, WSSubscriptionMessage, ) -from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel +from pyunifiprotect.exceptions import ClientError, NotAuthorized from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN +from .const import ( + CONF_DISABLE_RTSP, + DEVICES_THAT_ADOPT, + DISPATCH_ADOPT, + DISPATCH_CHANNELS, + DOMAIN, +) +from .utils import ( + async_dispatch_id as _ufpd, + async_get_devices, + async_get_devices_by_type, +) _LOGGER = logging.getLogger(__name__) +ProtectDeviceType = Union[ProtectAdoptableDeviceModel, NVR] @callback @@ -52,7 +68,8 @@ class ProtectData: self._entry = entry self._hass = hass self._update_interval = update_interval - self._subscriptions: dict[str, list[CALLBACK_TYPE]] = {} + self._subscriptions: dict[str, list[Callable[[ProtectDeviceType], None]]] = {} + self._pending_camera_ids: set[str] = set() self._unsub_interval: CALLBACK_TYPE | None = None self._unsub_websocket: CALLBACK_TYPE | None = None @@ -68,13 +85,10 @@ class ProtectData: self, device_types: Iterable[ModelType] ) -> Generator[ProtectAdoptableDeviceModel, None, None]: """Get all devices matching types.""" - for device_type in device_types: - attr = f"{device_type.value}s" - devices: dict[str, ProtectAdoptableDeviceModel] = getattr( - self.api.bootstrap, attr - ) - yield from devices.values() + yield from async_get_devices_by_type( + self.api.bootstrap, device_type + ).values() async def async_setup(self) -> None: """Subscribe and do the refresh.""" @@ -102,25 +116,54 @@ class ProtectData: try: updates = await self.api.update(force=force) - except NvrError: - if self.last_update_success: - _LOGGER.exception("Error while updating") - self.last_update_success = False - # manually trigger update to mark entities unavailable - self._async_process_updates(self.api.bootstrap) except NotAuthorized: await self.async_stop() _LOGGER.exception("Reauthentication required") self._entry.async_start_reauth(self._hass) self.last_update_success = False + except ClientError: + if self.last_update_success: + _LOGGER.exception("Error while updating") + self.last_update_success = False + # manually trigger update to mark entities unavailable + self._async_process_updates(self.api.bootstrap) else: self.last_update_success = True self._async_process_updates(updates) + @callback + def async_add_pending_camera_id(self, camera_id: str) -> None: + """ + Add pending camera. + + A "pending camera" is one that has been adopted by not had its camera channels + initialized yet. Will cause Websocket code to check for channels to be + initialized for the camera and issue a dispatch once they do. + """ + + self._pending_camera_ids.add(camera_id) + @callback def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: - if message.new_obj.model in DEVICES_WITH_ENTITIES: - self.async_signal_device_id_update(message.new_obj.id) + # removed packets are not processed yet + if message.new_obj is None or not getattr( + message.new_obj, "is_adopted_by_us", True + ): + return + + obj = message.new_obj + if isinstance(obj, (ProtectAdoptableDeviceModel, NVR)): + self._async_signal_device_update(obj) + if ( + obj.model == ModelType.CAMERA + and obj.id in self._pending_camera_ids + and "channels" in message.changed_data + ): + self._pending_camera_ids.remove(obj.id) + async_dispatcher_send( + self._hass, _ufpd(self._entry, DISPATCH_CHANNELS), obj + ) + # trigger update for all Cameras with LCD screens when NVR Doorbell settings updates if "doorbell_settings" in message.changed_data: _LOGGER.debug( @@ -129,19 +172,27 @@ class ProtectData: self.api.bootstrap.nvr.update_all_messages() for camera in self.api.bootstrap.cameras.values(): if camera.feature_flags.has_lcd_screen: - self.async_signal_device_id_update(camera.id) + self._async_signal_device_update(camera) # trigger updates for camera that the event references - elif isinstance(message.new_obj, Event): - if message.new_obj.camera is not None: - self.async_signal_device_id_update(message.new_obj.camera.id) - elif message.new_obj.light is not None: - self.async_signal_device_id_update(message.new_obj.light.id) - elif message.new_obj.sensor is not None: - self.async_signal_device_id_update(message.new_obj.sensor.id) + elif isinstance(obj, Event): + if obj.type == EventType.DEVICE_ADOPTED: + if obj.metadata is not None and obj.metadata.device_id is not None: + device = self.api.bootstrap.get_device_from_id( + obj.metadata.device_id + ) + if device is not None: + _LOGGER.debug("New device detected: %s", device.id) + async_dispatcher_send( + self._hass, _ufpd(self._entry, DISPATCH_ADOPT), device + ) + elif obj.camera is not None: + self._async_signal_device_update(obj.camera) + elif obj.light is not None: + self._async_signal_device_update(obj.light) + elif obj.sensor is not None: + self._async_signal_device_update(obj.sensor) # alert user viewport needs restart so voice clients can get new options - elif len(self.api.bootstrap.viewers) > 0 and isinstance( - message.new_obj, Liveview - ): + elif len(self.api.bootstrap.viewers) > 0 and isinstance(obj, Liveview): _LOGGER.warning( "Liveviews updated. Restart Home Assistant to update Viewport select options" ) @@ -154,47 +205,58 @@ class ProtectData: if updates is None: return - self.async_signal_device_id_update(self.api.bootstrap.nvr.id) - for device_type in DEVICES_THAT_ADOPT: - attr = f"{device_type.value}s" - devices: dict[str, ProtectDeviceModel] = getattr(self.api.bootstrap, attr) - for device_id in devices.keys(): - self.async_signal_device_id_update(device_id) + self._async_signal_device_update(self.api.bootstrap.nvr) + for device in async_get_devices(self.api.bootstrap, DEVICES_THAT_ADOPT): + self._async_signal_device_update(device) @callback def async_subscribe_device_id( - self, device_id: str, update_callback: CALLBACK_TYPE + self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> CALLBACK_TYPE: """Add an callback subscriber.""" if not self._subscriptions: self._unsub_interval = async_track_time_interval( self._hass, self.async_refresh, self._update_interval ) - self._subscriptions.setdefault(device_id, []).append(update_callback) + self._subscriptions.setdefault(mac, []).append(update_callback) def _unsubscribe() -> None: - self.async_unsubscribe_device_id(device_id, update_callback) + self.async_unsubscribe_device_id(mac, update_callback) return _unsubscribe @callback def async_unsubscribe_device_id( - self, device_id: str, update_callback: CALLBACK_TYPE + self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> None: """Remove a callback subscriber.""" - self._subscriptions[device_id].remove(update_callback) - if not self._subscriptions[device_id]: - del self._subscriptions[device_id] + self._subscriptions[mac].remove(update_callback) + if not self._subscriptions[mac]: + del self._subscriptions[mac] if not self._subscriptions and self._unsub_interval: self._unsub_interval() self._unsub_interval = None @callback - def async_signal_device_id_update(self, device_id: str) -> None: + def _async_signal_device_update(self, device: ProtectDeviceType) -> None: """Call the callbacks for a device_id.""" - if not self._subscriptions.get(device_id): + + if not self._subscriptions.get(device.mac): return - _LOGGER.debug("Updating device: %s", device_id) - for update_callback in self._subscriptions[device_id]: - update_callback() + _LOGGER.debug("Updating device: %s (%s)", device.name, device.mac) + for update_callback in self._subscriptions[device.mac]: + update_callback(device) + + +@callback +def async_ufp_instance_for_config_entry_ids( + hass: HomeAssistant, config_entry_ids: set[str] +) -> ProtectApiClient | None: + """Find the UFP instance for the config entry ids.""" + domain_data = hass.data[DOMAIN] + for config_entry_id in config_entry_ids: + if config_entry_id in domain_data: + protect_data: ProtectData = domain_data[config_entry_id] + return protect_data.api + return None diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index f8ceaeec9e6..e68e5cfb81d 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -14,6 +14,7 @@ from pyunifiprotect.data import ( Light, ModelType, ProtectAdoptableDeviceModel, + ProtectModelWithId, Sensor, StateType, Viewer, @@ -25,7 +26,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData -from .models import ProtectRequiredKeysMixin +from .models import PermRequired, ProtectRequiredKeysMixin from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) @@ -37,14 +38,28 @@ def _async_device_entities( klass: type[ProtectDeviceEntity], model_type: ModelType, descs: Sequence[ProtectRequiredKeysMixin], + ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: if len(descs) == 0: return [] entities: list[ProtectDeviceEntity] = [] - for device in data.get_by_types({model_type}): + devices = ( + [ufp_device] if ufp_device is not None else data.get_by_types({model_type}) + ) + for device in devices: + if not device.is_adopted_by_us: + continue + assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime)) for description in descs: + if description.ufp_perm is not None: + can_write = device.can_write(data.api.bootstrap.auth_user) + if description.ufp_perm == PermRequired.WRITE and not can_write: + continue + if description.ufp_perm == PermRequired.NO_WRITE and can_write: + continue + if description.ufp_required_field: required_field = get_nested_attr(device, description.ufp_required_field) if not required_field: @@ -61,7 +76,7 @@ def _async_device_entities( "Adding %s entity %s for %s", klass.__name__, description.name, - device.name, + device.display_name, ) return entities @@ -78,6 +93,7 @@ def async_all_device_entities( lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None, chime_descs: Sequence[ProtectRequiredKeysMixin] | None = None, all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: """Generate a list of all the device entities.""" all_descs = list(all_descs or []) @@ -88,14 +104,33 @@ def async_all_device_entities( lock_descs = list(lock_descs or []) + all_descs chime_descs = list(chime_descs or []) + all_descs - return ( - _async_device_entities(data, klass, ModelType.CAMERA, camera_descs) - + _async_device_entities(data, klass, ModelType.LIGHT, light_descs) - + _async_device_entities(data, klass, ModelType.SENSOR, sense_descs) - + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) - + _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs) - + _async_device_entities(data, klass, ModelType.CHIME, chime_descs) - ) + if ufp_device is None: + return ( + _async_device_entities(data, klass, ModelType.CAMERA, camera_descs) + + _async_device_entities(data, klass, ModelType.LIGHT, light_descs) + + _async_device_entities(data, klass, ModelType.SENSOR, sense_descs) + + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) + + _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs) + + _async_device_entities(data, klass, ModelType.CHIME, chime_descs) + ) + + descs = [] + if ufp_device.model == ModelType.CAMERA: + descs = camera_descs + elif ufp_device.model == ModelType.LIGHT: + descs = light_descs + elif ufp_device.model == ModelType.SENSOR: + descs = sense_descs + elif ufp_device.model == ModelType.VIEWPORT: + descs = viewer_descs + elif ufp_device.model == ModelType.DOORLOCK: + descs = lock_descs + elif ufp_device.model == ModelType.CHIME: + descs = chime_descs + + if len(descs) == 0 or ufp_device.model is None: + return [] + return _async_device_entities(data, klass, ufp_device.model, descs, ufp_device) class ProtectDeviceEntity(Entity): @@ -117,17 +152,17 @@ class ProtectDeviceEntity(Entity): self.device = device if description is None: - self._attr_unique_id = f"{self.device.id}" - self._attr_name = f"{self.device.name}" + self._attr_unique_id = f"{self.device.mac}" + self._attr_name = f"{self.device.display_name}" else: self.entity_description = description - self._attr_unique_id = f"{self.device.id}_{description.key}" + self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" - self._attr_name = f"{self.device.name} {name.title()}" + self._attr_name = f"{self.device.display_name} {name.title()}" self._attr_attribution = DEFAULT_ATTRIBUTION self._async_set_device_info() - self._async_update_device_from_protect() + self._async_update_device_from_protect(device) async def async_update(self) -> None: """Update the entity. @@ -139,7 +174,7 @@ class ProtectDeviceEntity(Entity): @callback def _async_set_device_info(self) -> None: self._attr_device_info = DeviceInfo( - name=self.device.name, + name=self.device.display_name, manufacturer=DEFAULT_BRAND, model=self.device.type, via_device=(DOMAIN, self.data.api.bootstrap.nvr.mac), @@ -149,12 +184,11 @@ class ProtectDeviceEntity(Entity): ) @callback - def _async_update_device_from_protect(self) -> None: + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: """Update Entity object from Protect device.""" if self.data.last_update_success: - assert self.device.model - devices = getattr(self.data.api.bootstrap, f"{self.device.model.value}s") - self.device = devices[self.device.id] + assert isinstance(device, ProtectAdoptableDeviceModel) + self.device = device is_connected = ( self.data.last_update_success and self.device.state == StateType.CONNECTED @@ -171,9 +205,9 @@ class ProtectDeviceEntity(Entity): self._attr_available = is_connected @callback - def _async_updated_event(self) -> None: + def _async_updated_event(self, device: ProtectModelWithId) -> None: """Call back for incoming data.""" - self._async_update_device_from_protect() + self._async_update_device_from_protect(device) self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -181,7 +215,7 @@ class ProtectDeviceEntity(Entity): await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_device_id( - self.device.id, self._async_updated_event + self.device.mac, self._async_updated_event ) ) @@ -207,14 +241,14 @@ class ProtectNVREntity(ProtectDeviceEntity): connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, identifiers={(DOMAIN, self.device.mac)}, manufacturer=DEFAULT_BRAND, - name=self.device.name, + name=self.device.display_name, model=self.device.type, sw_version=str(self.device.version), configuration_url=self.device.api.base_url, ) @callback - def _async_update_device_from_protect(self) -> None: + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: if self.data.last_update_success: self.device = self.data.api.bootstrap.nvr @@ -251,8 +285,8 @@ class EventThumbnailMixin(ProtectDeviceEntity): return attrs @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._event = self._async_get_event() attrs = self.extra_state_attributes or {} diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 8a6f2f5a371..588b99b38d7 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -4,16 +4,23 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Light +from pyunifiprotect.data import ( + Light, + ModelType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -25,16 +32,27 @@ async def async_setup_entry( ) -> None: """Set up lights for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - entities = [ - ProtectLight( - data, - device, - ) - for device in data.api.bootstrap.lights.values() - ] - if not entities: - return + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not device.is_adopted_by_us: + return + + if device.model == ModelType.LIGHT and device.can_write( + data.api.bootstrap.auth_user + ): + async_add_entities([ProtectLight(data, device)]) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + + entities = [] + for device in data.api.bootstrap.lights.values(): + if not device.is_adopted_by_us: + continue + + if device.can_write(data.api.bootstrap.auth_user): + entities.append(ProtectLight(data, device)) async_add_entities(entities) @@ -59,8 +77,8 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_is_on = self.device.is_light_on self._attr_brightness = unifi_brightness_to_hass( self.device.light_device_settings.led_level diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index c4d56dd1e71..0a203308d1e 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -4,16 +4,23 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Doorlock, LockStatusType +from pyunifiprotect.data import ( + Doorlock, + LockStatusType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, +) from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -26,14 +33,26 @@ async def async_setup_entry( """Set up locks on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - ProtectLock( - data, - lock, - ) - for lock in data.api.bootstrap.doorlocks.values() + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not device.is_adopted_by_us: + return + + if isinstance(device, Doorlock): + async_add_entities([ProtectLock(data, device)]) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) ) + entities = [] + for device in data.api.bootstrap.doorlocks.values(): + if not device.is_adopted_by_us: + continue + + entities.append(ProtectLock(data, device)) + + async_add_entities(entities) + class ProtectLock(ProtectDeviceEntity, LockEntity): """A Ubiquiti UniFi Protect Speaker.""" @@ -53,11 +72,11 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): LockEntityDescription(key="lock"), ) - self._attr_name = f"{self.device.name} Lock" + self._attr_name = f"{self.device.display_name} Lock" @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_is_locked = False self._attr_is_locking = False @@ -82,10 +101,10 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - _LOGGER.debug("Unlocking %s", self.device.name) + _LOGGER.debug("Unlocking %s", self.device.display_name) return await self.device.open_lock() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - _LOGGER.debug("Locking %s", self.device.name) + _LOGGER.debug("Locking %s", self.device.display_name) return await self.device.close_lock() diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 2554d12c866..9aeb8b48050 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.9.2", "unifi-discovery==1.1.4"], + "requirements": ["pyunifiprotect==4.0.9", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 1acd14be130..d4046e4b8b7 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Camera +from pyunifiprotect.data import Camera, ProtectAdoptableDeviceModel, ProtectModelWithId from pyunifiprotect.exceptions import StreamError from homeassistant.components import media_source @@ -23,11 +23,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PLAYING from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -40,17 +42,26 @@ async def async_setup_entry( """Discover cameras with speakers on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - ProtectMediaPlayer( - data, - camera, - ) - for camera in data.api.bootstrap.cameras.values() - if camera.feature_flags.has_speaker - ] + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not device.is_adopted_by_us: + return + + if isinstance(device, Camera) and device.feature_flags.has_speaker: + async_add_entities([ProtectMediaPlayer(data, device)]) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) ) + entities = [] + for device in data.api.bootstrap.cameras.values(): + if not device.is_adopted_by_us: + continue + if device.feature_flags.has_speaker: + entities.append(ProtectMediaPlayer(data, device)) + + async_add_entities(entities) + class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): """A Ubiquiti UniFi Protect Speaker.""" @@ -79,12 +90,12 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): ), ) - self._attr_name = f"{self.device.name} Speaker" + self._attr_name = f"{self.device.display_name} Speaker" self._attr_media_content_type = MEDIA_TYPE_MUSIC @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_volume_level = float(self.device.speaker_settings.volume / 100) if ( @@ -108,9 +119,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): self.device.talkback_stream is not None and self.device.talkback_stream.is_running ): - _LOGGER.debug("Stopping playback for %s Speaker", self.device.name) + _LOGGER.debug("Stopping playback for %s Speaker", self.device.display_name) await self.device.stop_audio() - self._async_updated_event() + self._async_updated_event(self.device) async def async_play_media( self, media_type: str, media_id: str, **kwargs: Any @@ -126,7 +137,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): if media_type != MEDIA_TYPE_MUSIC: raise HomeAssistantError("Only music media type is supported") - _LOGGER.debug("Playing Media %s for %s Speaker", media_id, self.device.name) + _LOGGER.debug( + "Playing Media %s for %s Speaker", media_id, self.device.display_name + ) await self.async_media_stop() try: await self.device.play_audio(media_id, blocking=False) @@ -134,11 +147,11 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): raise HomeAssistantError(err) from err else: # update state after starting player - self._async_updated_event() + self._async_updated_event(self.device) # wait until player finishes to update state again await self.device.wait_until_audio_completes() - self._async_updated_event() + self._async_updated_event(self.device) async def async_browse_media( self, media_content_type: str | None = None, media_content_id: str | None = None diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py new file mode 100644 index 00000000000..893ca3e458a --- /dev/null +++ b/homeassistant/components/unifiprotect/migrate.py @@ -0,0 +1,159 @@ +"""UniFi Protect data migrations.""" +from __future__ import annotations + +import logging + +from aiohttp.client_exceptions import ServerDisconnectedError +from pyunifiprotect import ProtectApiClient +from pyunifiprotect.data import NVR, Bootstrap, ProtectAdoptableDeviceModel +from pyunifiprotect.exceptions import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er + +_LOGGER = logging.getLogger(__name__) + + +async def async_migrate_data( + hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient +) -> None: + """Run all valid UniFi Protect data migrations.""" + + _LOGGER.debug("Start Migrate: async_migrate_buttons") + await async_migrate_buttons(hass, entry, protect) + _LOGGER.debug("Completed Migrate: async_migrate_buttons") + + _LOGGER.debug("Start Migrate: async_migrate_device_ids") + await async_migrate_device_ids(hass, entry, protect) + _LOGGER.debug("Completed Migrate: async_migrate_device_ids") + + +async def async_get_bootstrap(protect: ProtectApiClient) -> Bootstrap: + """Get UniFi Protect bootstrap or raise appropriate HA error.""" + + try: + bootstrap = await protect.get_bootstrap() + except (TimeoutError, ClientError, ServerDisconnectedError) as err: + raise ConfigEntryNotReady from err + + return bootstrap + + +async def async_migrate_buttons( + hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient +) -> None: + """ + Migrate existing Reboot button unique IDs from {device_id} to {deivce_id}_reboot. + + This allows for additional types of buttons that are outside of just a reboot button. + + Added in 2022.6.0. + """ + + registry = er.async_get(hass) + to_migrate = [] + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): + if entity.domain == Platform.BUTTON and "_" not in entity.unique_id: + _LOGGER.debug("Button %s needs migration", entity.entity_id) + to_migrate.append(entity) + + if len(to_migrate) == 0: + _LOGGER.debug("No button entities need migration") + return + + bootstrap = await async_get_bootstrap(protect) + count = 0 + for button in to_migrate: + device = bootstrap.get_device_from_id(button.unique_id) + if device is None: + continue + + new_unique_id = f"{device.id}_reboot" + _LOGGER.debug( + "Migrating entity %s (old unique_id: %s, new unique_id: %s)", + button.entity_id, + button.unique_id, + new_unique_id, + ) + try: + registry.async_update_entity(button.entity_id, new_unique_id=new_unique_id) + except ValueError: + _LOGGER.warning( + "Could not migrate entity %s (old unique_id: %s, new unique_id: %s)", + button.entity_id, + button.unique_id, + new_unique_id, + ) + else: + count += 1 + + if count < len(to_migrate): + _LOGGER.warning("Failed to migate %s reboot buttons", len(to_migrate) - count) + + +async def async_migrate_device_ids( + hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient +) -> None: + """ + Migrate unique IDs from {device_id}_{name} format to {mac}_{name} format. + + This makes devices persist better with in HA. Anything a device is unadopted/readopted or + the Protect instance has to rebuild the disk array, the device IDs of Protect devices + can change. This causes a ton of orphaned entities and loss of historical data. MAC + addresses are the one persistent identifier a device has that does not change. + + Added in 2022.7.0. + """ + + registry = er.async_get(hass) + to_migrate = [] + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): + parts = entity.unique_id.split("_") + # device ID = 24 characters, MAC = 12 + if len(parts[0]) == 24: + _LOGGER.debug("Entity %s needs migration", entity.entity_id) + to_migrate.append(entity) + + if len(to_migrate) == 0: + _LOGGER.debug("No entities need migration to MAC address ID") + return + + bootstrap = await async_get_bootstrap(protect) + count = 0 + for entity in to_migrate: + parts = entity.unique_id.split("_") + if parts[0] == bootstrap.nvr.id: + device: NVR | ProtectAdoptableDeviceModel | None = bootstrap.nvr + else: + device = bootstrap.get_device_from_id(parts[0]) + + if device is None: + continue + + new_unique_id = device.mac + if len(parts) > 1: + new_unique_id = f"{device.mac}_{'_'.join(parts[1:])}" + _LOGGER.debug( + "Migrating entity %s (old unique_id: %s, new unique_id: %s)", + entity.entity_id, + entity.unique_id, + new_unique_id, + ) + try: + registry.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + except ValueError as err: + _LOGGER.warning( + "Could not migrate entity %s (old unique_id: %s, new unique_id: %s): %s", + entity.entity_id, + entity.unique_id, + new_unique_id, + err, + ) + else: + count += 1 + + if count < len(to_migrate): + _LOGGER.warning("Failed to migrate %s entities", len(to_migrate) - count) diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index c28e1757722..dee2006b429 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +from enum import Enum import logging -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, Union -from pyunifiprotect.data import ProtectDeviceModel +from pyunifiprotect.data import NVR, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription @@ -14,7 +15,14 @@ from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=ProtectDeviceModel) +T = TypeVar("T", bound=Union[ProtectAdoptableDeviceModel, NVR]) + + +class PermRequired(int, Enum): + """Type of permission level required for entity.""" + + NO_WRITE = 1 + WRITE = 2 @dataclass @@ -25,6 +33,7 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): ufp_value: str | None = None ufp_value_fn: Callable[[T], Any] | None = None ufp_enabled: str | None = None + ufp_perm: PermRequired | None = None def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" @@ -54,7 +63,7 @@ class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): async def ufp_set(self, obj: T, value: Any) -> None: """Set value for UniFi Protect device.""" - _LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.name) + _LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.display_name) if self.ufp_set_method is not None: await getattr(obj, self.ufp_set_method)(value) elif self.ufp_set_method_fn is not None: diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 4ebdd17f5c9..ed9faf4da40 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -4,19 +4,27 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from pyunifiprotect.data import Camera, Doorlock, Light +from pyunifiprotect.data import ( + Camera, + Doorlock, + Light, + ProtectAdoptableDeviceModel, + ProtectModelWithId, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TIME_SECONDS +from homeassistant.const import PERCENTAGE, TIME_SECONDS from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_dispatch_id as _ufpd @dataclass @@ -63,30 +71,35 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_required_field="feature_flags.has_wdr", ufp_value="isp_settings.wdr", ufp_set_method="set_wdr_level", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( key="mic_level", name="Microphone Level", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field="feature_flags.has_mic", ufp_value="mic_volume", ufp_set_method="set_mic_volume", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( key="zoom_position", name="Zoom Level", icon="mdi:magnify-plus-outline", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field="feature_flags.can_optical_zoom", ufp_value="isp_settings.zoom_position", ufp_set_method="set_camera_zoom", + ufp_perm=PermRequired.WRITE, ), ) @@ -96,25 +109,28 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Motion Sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field=None, ufp_value="light_device_settings.pir_sensitivity", ufp_set_method="set_sensitivity", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( key="duration", name="Auto-shutoff Duration", icon="mdi:camera-timer", entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, ufp_min=15, ufp_max=900, ufp_step=15, ufp_required_field=None, ufp_value_fn=_get_pir_duration, ufp_set_method_fn=_set_pir_duration, + ufp_perm=PermRequired.WRITE, ), ) @@ -124,12 +140,14 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Motion Sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field=None, ufp_value="motion_settings.sensitivity", ufp_set_method="set_motion_sensitivity", + ufp_perm=PermRequired.WRITE, ), ) @@ -139,13 +157,14 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Auto-lock Timeout", icon="mdi:walk", entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, ufp_min=0, ufp_max=3600, ufp_step=15, ufp_required_field=None, ufp_value_fn=_get_auto_close, ufp_set_method_fn=_set_auto_close, + ufp_perm=PermRequired.WRITE, ), ) @@ -155,11 +174,13 @@ CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Volume", icon="mdi:speaker", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_value="volume", ufp_set_method="set_volume", + ufp_perm=PermRequired.WRITE, ), ) @@ -171,6 +192,24 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectNumbers, + camera_descs=CAMERA_NUMBERS, + light_descs=LIGHT_NUMBERS, + sense_descs=SENSE_NUMBERS, + lock_descs=DOORLOCK_NUMBERS, + chime_descs=CHIME_NUMBERS, + ufp_device=device, + ) + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectNumbers, @@ -198,15 +237,15 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): ) -> None: """Initialize the Number Entities.""" super().__init__(data, device, description) - self._attr_max_value = self.entity_description.ufp_max - self._attr_min_value = self.entity_description.ufp_min - self._attr_step = self.entity_description.ufp_step + self._attr_native_max_value = self.entity_description.ufp_max + self._attr_native_min_value = self.entity_description.ufp_min + self._attr_native_step = self.entity_description.ufp_step @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() - self._attr_value = self.entity_description.get_ufp_value(self.device) + 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) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 2c6c5fa4cc6..e398e6692b0 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -19,6 +19,8 @@ from pyunifiprotect.data import ( LightModeEnableType, LightModeType, MountType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, RecordingMode, Sensor, Viewer, @@ -31,13 +33,15 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.util.dt import utcnow -from .const import ATTR_DURATION, ATTR_MESSAGE, DOMAIN, TYPE_EMPTY_VALUE +from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -141,7 +145,7 @@ def _get_doorbell_options(api: ProtectApiClient) -> list[dict[str, Any]]: def _get_paired_camera_options(api: ProtectApiClient) -> list[dict[str, Any]]: options = [{"id": TYPE_EMPTY_VALUE, "name": "Not Paired"}] for camera in api.bootstrap.cameras.values(): - options.append({"id": camera.id, "name": camera.name or camera.type}) + options.append({"id": camera.id, "name": camera.display_name or camera.type}) return options @@ -150,16 +154,6 @@ def _get_viewer_current(obj: Viewer) -> str: return obj.liveview_id -def _get_light_motion_current(obj: Light) -> str: - # a bit of extra to allow On Motion Always/Dark - if ( - obj.light_mode_settings.mode == LightModeType.MOTION - and obj.light_mode_settings.enable_at == LightModeEnableType.DARK - ): - return f"{LightModeType.MOTION.value}Dark" - return obj.light_mode_settings.mode.value - - def _get_doorbell_current(obj: Camera) -> str | None: if obj.lcd_message is None: return None @@ -207,6 +201,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=RecordingMode, ufp_value="recording_settings.mode", ufp_set_method="set_recording_mode", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription( key="infrared", @@ -218,6 +213,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=IRLEDMode, ufp_value="isp_settings.ir_led_mode", ufp_set_method="set_ir_led_model", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Camera]( key="doorbell_text", @@ -229,6 +225,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value_fn=_get_doorbell_current, ufp_options_fn=_get_doorbell_options, ufp_set_method_fn=_set_doorbell_message, + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription( key="chime_type", @@ -240,6 +237,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=ChimeType, ufp_value="chime_type", ufp_set_method="set_chime_type", + ufp_perm=PermRequired.WRITE, ), ) @@ -250,8 +248,9 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( icon="mdi:spotlight", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, - ufp_value_fn=_get_light_motion_current, + ufp_value_fn=async_get_light_motion_current, ufp_set_method_fn=_set_light_mode, + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Light]( key="paired_camera", @@ -261,6 +260,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -274,6 +274,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=MountType, ufp_value="mount_type", ufp_set_method="set_mount_type", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Sensor]( key="paired_camera", @@ -283,6 +284,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -295,6 +297,7 @@ DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -307,6 +310,7 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_options_fn=_get_viewer_options, ufp_value_fn=_get_viewer_current, ufp_set_method_fn=_set_liveview, + ufp_perm=PermRequired.WRITE, ), ) @@ -318,6 +322,24 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectSelects, + camera_descs=CAMERA_SELECTS, + light_descs=LIGHT_SELECTS, + sense_descs=SENSE_SELECTS, + viewer_descs=VIEWER_SELECTS, + lock_descs=DOORLOCK_SELECTS, + ufp_device=device, + ) + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectSelects, @@ -351,12 +373,12 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): ) -> None: """Initialize the unifi protect select entity.""" super().__init__(data, device, description) - self._attr_name = f"{self.device.name} {self.entity_description.name}" + self._attr_name = f"{self.device.display_name} {self.entity_description.name}" self._async_set_options() @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) # entities with categories are not exposed for voice and safe to update dynamically if ( @@ -419,7 +441,10 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): timeout_msg = f" with timeout of {duration} minute(s)" _LOGGER.debug( - 'Setting message for %s to "%s"%s', self.device.name, message, timeout_msg + 'Setting message for %s to "%s"%s', + self.device.display_name, + message, + timeout_msg, ) await self.device.set_lcd_text( DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index c30cc7fb80f..7a9f4652a2e 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -10,8 +10,10 @@ from pyunifiprotect.data import ( NVR, Camera, Event, + Light, ProtectAdoptableDeviceModel, ProtectDeviceModel, + ProtectModelWithId, Sensor, ) @@ -34,10 +36,11 @@ from homeassistant.const import ( TIME_SECONDS, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ( EventThumbnailMixin, @@ -45,7 +48,8 @@ from .entity import ( ProtectNVREntity, async_all_device_entities, ) -from .models import ProtectRequiredKeysMixin, T +from .models import PermRequired, ProtectRequiredKeysMixin, T +from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -105,7 +109,7 @@ def _get_alarm_sound(obj: Sensor) -> str: ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( - ProtectSensorEntityDescription[ProtectDeviceModel]( + ProtectSensorEntityDescription( key="uptime", name="Uptime", icon="mdi:clock", @@ -171,7 +175,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_RATE_BYTES_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.storage.rate", + ufp_value="stats.storage.rate_per_second", precision=2, ), ProtectSensorEntityDescription( @@ -196,6 +200,51 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_value="last_ring", entity_registry_enabled_default=False, ), + ProtectSensorEntityDescription( + key="mic_level", + name="Microphone Level", + icon="mdi:microphone", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_mic", + ufp_value="mic_volume", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="recording_mode", + name="Recording Mode", + icon="mdi:video-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="recording_settings.mode", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="infrared", + name="Infrared Mode", + icon="mdi:circle-opacity", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_led_ir", + ufp_value="isp_settings.ir_led_mode", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="doorbell_text", + name="Doorbell Text", + icon="mdi:card-text", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_lcd_screen", + ufp_value="lcd_message.text", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="chime_type", + name="Chime Type", + icon="mdi:bell", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ufp_required_field="feature_flags.has_chime", + ufp_value="chime_type", + ), ) CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -283,6 +332,31 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_value="tampering_detected_at", entity_registry_enabled_default=False, ), + ProtectSensorEntityDescription( + key="sensitivity", + name="Motion Sensitivity", + icon="mdi:walk", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="motion_settings.sensitivity", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="mount_type", + name="Mount Type", + icon="mdi:screwdriver", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="mount_type", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="camera.display_name", + ufp_perm=PermRequired.NO_WRITE, + ), ) DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -295,10 +369,18 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="battery_status.percentage", ), + ProtectSensorEntityDescription( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="camera.display_name", + ufp_perm=PermRequired.NO_WRITE, + ), ) NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( - ProtectSensorEntityDescription[ProtectDeviceModel]( + ProtectSensorEntityDescription( key="uptime", name="Uptime", icon="mdi:clock", @@ -438,6 +520,31 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_value="last_motion", entity_registry_enabled_default=False, ), + ProtectSensorEntityDescription( + key="sensitivity", + name="Motion Sensitivity", + icon="mdi:walk", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="light_device_settings.pir_sensitivity", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription[Light]( + key="light_motion", + name="Light Mode", + icon="mdi:spotlight", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value_fn=async_get_light_motion_current, + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="camera.display_name", + ufp_perm=PermRequired.NO_WRITE, + ), ) MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -458,6 +565,26 @@ CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( icon="mdi:bell", ufp_value="last_ring", ), + ProtectSensorEntityDescription( + key="volume", + name="Volume", + icon="mdi:speaker", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="volume", + ufp_perm=PermRequired.NO_WRITE, + ), +) + +VIEWER_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( + ProtectSensorEntityDescription( + key="viewer", + name="Liveview", + icon="mdi:view-dashboard", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="liveview.name", + ufp_perm=PermRequired.NO_WRITE, + ), ) @@ -468,6 +595,28 @@ async def async_setup_entry( ) -> None: """Set up sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectDeviceSensor, + all_descs=ALL_DEVICES_SENSORS, + camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, + sense_descs=SENSE_SENSORS, + light_descs=LIGHT_SENSORS, + lock_descs=DOORLOCK_SENSORS, + chime_descs=CHIME_SENSORS, + viewer_descs=VIEWER_SENSORS, + ufp_device=device, + ) + if device.is_adopted_by_us and isinstance(device, Camera): + entities += _async_motion_entities(data, ufp_device=device) + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectDeviceSensor, @@ -477,6 +626,7 @@ async def async_setup_entry( light_descs=LIGHT_SENSORS, lock_descs=DOORLOCK_SENSORS, chime_descs=CHIME_SENSORS, + viewer_descs=VIEWER_SENSORS, ) entities += _async_motion_entities(data) entities += _async_nvr_entities(data) @@ -487,15 +637,22 @@ async def async_setup_entry( @callback def _async_motion_entities( data: ProtectData, + ufp_device: Camera | None = None, ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] - for device in data.api.bootstrap.cameras.values(): + devices = ( + data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device] + ) + for device in devices: + if not device.is_adopted_by_us: + continue + for description in MOTION_TRIP_SENSORS: entities.append(ProtectDeviceSensor(data, device, description)) _LOGGER.debug( "Adding trip sensor entity %s for %s", description.name, - device.name, + device.display_name, ) if not device.feature_flags.has_smart_detect: @@ -506,7 +663,7 @@ def _async_motion_entities( _LOGGER.debug( "Adding sensor entity %s for %s", description.name, - device.name, + device.display_name, ) return entities @@ -540,8 +697,8 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): super().__init__(data, device, description) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + 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) @@ -560,8 +717,8 @@ class ProtectNVRSensor(ProtectNVREntity, SensorEntity): super().__init__(data, device, description) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + 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) @@ -585,9 +742,9 @@ class ProtectEventSensor(ProtectDeviceSensor, EventThumbnailMixin): return event @callback - def _async_update_device_from_protect(self) -> None: + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: # do not call ProtectDeviceSensor method since we want event to get value here - EventThumbnailMixin._async_update_device_from_protect(self) + EventThumbnailMixin._async_update_device_from_protect(self, device) if self._event is None: self._attr_native_value = OBJECT_TYPE_NONE else: diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index f8aa446f857..915c51b6c0a 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio import functools -from typing import Any +from typing import Any, cast from pydantic import ValidationError from pyunifiprotect.api import ProtectApiClient -from pyunifiprotect.exceptions import BadRequest +from pyunifiprotect.data import Chime +from pyunifiprotect.exceptions import ClientError import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -24,7 +25,7 @@ from homeassistant.helpers.service import async_extract_referenced_entity_ids from homeassistant.util.read_only_dict import ReadOnlyDict from .const import ATTR_MESSAGE, DOMAIN -from .data import ProtectData +from .data import async_ufp_instance_for_config_entry_ids SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text" SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text" @@ -59,18 +60,6 @@ CHIME_PAIRED_SCHEMA = vol.All( ) -def _async_ufp_instance_for_config_entry_ids( - hass: HomeAssistant, config_entry_ids: set[str] -) -> ProtectApiClient | None: - """Find the UFP instance for the config entry ids.""" - domain_data = hass.data[DOMAIN] - for config_entry_id in config_entry_ids: - if config_entry_id in domain_data: - protect_data: ProtectData = domain_data[config_entry_id] - return protect_data.api - return None - - @callback def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient: device_registry = dr.async_get(hass) @@ -81,7 +70,7 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl return _async_get_ufp_instance(hass, device_entry.via_device_id) config_entry_ids = device_entry.config_entries - if ufp_instance := _async_ufp_instance_for_config_entry_ids(hass, config_entry_ids): + if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids): return ufp_instance raise HomeAssistantError(f"No device found for device id: {device_id}") @@ -111,7 +100,7 @@ async def _async_service_call_nvr( await asyncio.gather( *(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for i in instances) ) - except (BadRequest, ValidationError) as err: + except (ClientError, ValidationError) as err: raise HomeAssistantError(str(err)) from err @@ -134,8 +123,8 @@ async def set_default_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> N @callback -def _async_unique_id_to_ufp_device_id(unique_id: str) -> str: - """Extract the UFP device id from the registry entry unique id.""" +def _async_unique_id_to_mac(unique_id: str) -> str: + """Extract the MAC address from the registry entry unique id.""" return unique_id.split("_")[0] @@ -148,10 +137,12 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> chime_button = entity_registry.async_get(entity_id) assert chime_button is not None assert chime_button.device_id is not None - chime_ufp_device_id = _async_unique_id_to_ufp_device_id(chime_button.unique_id) + chime_mac = _async_unique_id_to_mac(chime_button.unique_id) instance = _async_get_ufp_instance(hass, chime_button.device_id) - chime = instance.bootstrap.chimes[chime_ufp_device_id] + chime = instance.bootstrap.get_device_from_mac(chime_mac) + chime = cast(Chime, chime) + assert chime is not None call.data = ReadOnlyDict(call.data.get("doorbells") or {}) doorbell_refs = async_extract_referenced_entity_ids(hass, call) @@ -166,10 +157,9 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> != BinarySensorDeviceClass.OCCUPANCY ): continue - doorbell_ufp_device_id = _async_unique_id_to_ufp_device_id( - doorbell_sensor.unique_id - ) - camera = instance.bootstrap.cameras[doorbell_ufp_device_id] + doorbell_mac = _async_unique_id_to_mac(doorbell_sensor.unique_id) + camera = instance.bootstrap.get_device_from_mac(doorbell_mac) + assert camera is not None doorbell_ids.add(camera.id) chime.camera_ids = sorted(doorbell_ids) await chime.save_device() diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 971c637a8c2..5bc4e1f17eb 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -15,13 +15,15 @@ from pyunifiprotect.data import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -36,10 +38,6 @@ class ProtectSwitchEntityDescription( _KEY_PRIVACY_MODE = "privacy_mode" -def _get_is_highfps(obj: Camera) -> bool: - return bool(obj.video_mode == VideoMode.HIGH_FPS) - - async def _set_highfps(obj: Camera, value: bool) -> None: if value: await obj.set_video_mode(VideoMode.HIGH_FPS) @@ -56,6 +54,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="status_light", @@ -65,6 +64,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_led_status", ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="hdr_mode", @@ -74,6 +74,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_hdr", ufp_value="hdr_mode", ufp_set_method="set_hdr", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription[Camera]( key="high_fps", @@ -81,8 +82,9 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( icon="mdi:video-high-definition", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_highfps", - ufp_value_fn=_get_is_highfps, + ufp_value="is_high_fps_enabled", ufp_set_method_fn=_set_highfps, + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key=_KEY_PRIVACY_MODE, @@ -91,6 +93,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_privacy_mask", ufp_value="is_privacy_on", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="system_sounds", @@ -100,6 +103,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_speaker", ufp_value="speaker_settings.are_system_sounds_enabled", ufp_set_method="set_system_sounds", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_name", @@ -108,6 +112,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_name_enabled", ufp_set_method="set_osd_name", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_date", @@ -116,6 +121,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_date_enabled", ufp_set_method="set_osd_date", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_logo", @@ -124,6 +130,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_logo_enabled", ufp_set_method="set_osd_logo", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_bitrate", @@ -132,6 +139,16 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_debug_enabled", ufp_set_method="set_osd_bitrate", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="motion", + name="Detections: Motion", + icon="mdi:run-fast", + entity_category=EntityCategory.CONFIG, + ufp_value="recording_settings.enable_motion_detection", + ufp_set_method="set_motion_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_person", @@ -141,6 +158,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_person", ufp_value="is_person_detection_on", ufp_set_method="set_person_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_vehicle", @@ -150,6 +168,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_vehicle", ufp_value="is_vehicle_detection_on", ufp_set_method="set_vehicle_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_face", @@ -159,6 +178,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_face", ufp_value="is_face_detection_on", ufp_set_method="set_face_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_package", @@ -168,6 +188,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_package", ufp_value="is_package_detection_on", ufp_set_method="set_package_detection", + ufp_perm=PermRequired.WRITE, ), ) @@ -179,6 +200,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="motion", @@ -187,6 +209,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="motion_settings.is_enabled", ufp_set_method="set_motion_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="temperature", @@ -195,6 +218,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="temperature_settings.is_enabled", ufp_set_method="set_temperature_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="humidity", @@ -203,6 +227,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="humidity_settings.is_enabled", ufp_set_method="set_humidity_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="light", @@ -211,6 +236,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="light_settings.is_enabled", ufp_set_method="set_light_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="alarm", @@ -218,6 +244,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="alarm_settings.is_enabled", ufp_set_method="set_alarm_status", + ufp_perm=PermRequired.WRITE, ), ) @@ -231,6 +258,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="status_light", @@ -239,6 +267,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="light_device_settings.is_indicator_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ) @@ -250,6 +279,7 @@ DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ) @@ -262,6 +292,7 @@ VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ) @@ -273,6 +304,24 @@ async def async_setup_entry( ) -> None: """Set up sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectSwitch, + camera_descs=CAMERA_SWITCHES, + light_descs=LIGHT_SWITCHES, + sense_descs=SENSE_SWITCHES, + lock_descs=DOORLOCK_SWITCHES, + viewer_descs=VIEWER_SWITCHES, + ufp_device=device, + ) + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectSwitch, @@ -298,7 +347,7 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): ) -> None: """Initialize an UniFi Protect Switch.""" super().__init__(data, device, description) - self._attr_name = f"{self.device.name} {self.entity_description.name}" + self._attr_name = f"{self.device.display_name} {self.entity_description.name}" self._switch_type = self.entity_description.key if not isinstance(self.device, Camera): @@ -332,7 +381,9 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): if self._switch_type == _KEY_PRIVACY_MODE: assert isinstance(self.device, Camera) - _LOGGER.debug("Setting Privacy Mode to false for %s", self.device.name) + _LOGGER.debug( + "Setting Privacy Mode to false for %s", self.device.display_name + ) await self.device.set_privacy( False, self._previous_mic_level, self._previous_record_mode ) diff --git a/homeassistant/components/unifiprotect/translations/es.json b/homeassistant/components/unifiprotect/translations/es.json index 9ca1b56cf01..eb52d0222fc 100644 --- a/homeassistant/components/unifiprotect/translations/es.json +++ b/homeassistant/components/unifiprotect/translations/es.json @@ -9,11 +9,15 @@ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "protect_version": "La versi\u00f3n m\u00ednima requerida es v1.20.0. Actualice UniFi Protect y vuelva a intentarlo." }, + "flow_title": "{name} ({ip_address})", "step": { "discovery_confirm": { "data": { + "password": "Contrase\u00f1a", "username": "Usuario" - } + }, + "description": "\u00bfQuieres configurar {name} ({ip_address})? Necesitar\u00e1 un usuario local creado en su consola UniFi OS para iniciar sesi\u00f3n. Los usuarios de Ubiquiti Cloud no funcionar\u00e1n. Para m\u00e1s informaci\u00f3n: {local_user_documentation_url}", + "title": "UniFi Protect descubierto" }, "reauth_confirm": { "data": { @@ -32,6 +36,7 @@ "username": "Nombre de usuario", "verify_ssl": "Verificar el certificado SSL" }, + "description": "Necesitar\u00e1 un usuario local creado en su consola UniFi OS para iniciar sesi\u00f3n. Los usuarios de Ubiquiti Cloud no funcionar\u00e1n. Para m\u00e1s informaci\u00f3n: {local_user_documentation_url}", "title": "Configuraci\u00f3n de UniFi Protect" } } diff --git a/homeassistant/components/unifiprotect/translations/sv.json b/homeassistant/components/unifiprotect/translations/sv.json index e2bfaa9118c..702dcbecdb7 100644 --- a/homeassistant/components/unifiprotect/translations/sv.json +++ b/homeassistant/components/unifiprotect/translations/sv.json @@ -2,9 +2,20 @@ "config": { "step": { "discovery_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + }, "title": "UniFi Protect uppt\u00e4ckt" }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + }, "description": "Du beh\u00f6ver en lokal anv\u00e4ndare skapad i din UniFi OS-konsol f\u00f6r att logga in med. Ubiquiti Cloud-anv\u00e4ndare kommer inte att fungera. F\u00f6r mer information: {local_user_documentation_url}" } } diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 559cfd37660..808117aac9e 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -1,13 +1,25 @@ """UniFi Protect Integration utils.""" from __future__ import annotations +from collections.abc import Generator, Iterable import contextlib from enum import Enum import socket from typing import Any +from pyunifiprotect.data import ( + Bootstrap, + Light, + LightModeEnableType, + LightModeType, + ProtectAdoptableDeviceModel, +) + +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN, ModelType + def get_nested_attr(obj: Any, attr: str) -> Any: """Fetch a nested attribute.""" @@ -51,3 +63,46 @@ async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: None, ) return None + + +@callback +def async_get_devices_by_type( + bootstrap: Bootstrap, device_type: ModelType +) -> dict[str, ProtectAdoptableDeviceModel]: + """Get devices by type.""" + + devices: dict[str, ProtectAdoptableDeviceModel] = getattr( + bootstrap, f"{device_type.value}s" + ) + return devices + + +@callback +def async_get_devices( + bootstrap: Bootstrap, model_type: Iterable[ModelType] +) -> Generator[ProtectAdoptableDeviceModel, None, None]: + """Return all device by type.""" + return ( + device + for device_type in model_type + for device in async_get_devices_by_type(bootstrap, device_type).values() + ) + + +@callback +def async_get_light_motion_current(obj: Light) -> str: + """Get light motion mode for Flood Light.""" + + if ( + obj.light_mode_settings.mode == LightModeType.MOTION + and obj.light_mode_settings.enable_at == LightModeEnableType.DARK + ): + return f"{LightModeType.MOTION.value}Dark" + return obj.light_mode_settings.mode.value + + +@callback +def async_dispatch_id(entry: ConfigEntry, dispatch: str) -> str: + """Generate entry specific dispatch ID.""" + + return f"{DOMAIN}.{entry.entry_id}.{dispatch}" diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 7ffd8b9d13d..f33db3827af 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -72,6 +72,8 @@ from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_ON, + STATE_PAUSED, + STATE_PLAYING, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -94,8 +96,15 @@ CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" -OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN] - +STATES_ORDER = [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + STATE_OFF, + STATE_IDLE, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +] ATTRS_SCHEMA = cv.schema_with_slug_keys(cv.string) CMD_SCHEMA = cv.schema_with_slug_keys(cv.SERVICE_SCHEMA) @@ -614,9 +623,15 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_update(self): """Update state in HA.""" - for child_name in self._children: - child_state = self.hass.states.get(child_name) - if child_state and child_state.state not in OFF_STATES: - self._child_state = child_state - return self._child_state = None + for child_name in self._children: + if (child_state := self.hass.states.get(child_name)) and STATES_ORDER.index( + child_state.state + ) >= STATES_ORDER.index(STATE_IDLE): + if self._child_state: + if STATES_ORDER.index(child_state.state) > STATES_ORDER.index( + self._child_state.state + ): + self._child_state = child_state + else: + self._child_state = child_state diff --git a/homeassistant/components/upcloud/translations/sv.json b/homeassistant/components/upcloud/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/upcloud/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 07560f7413f..27d69f9c509 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -5,13 +5,10 @@ import asyncio from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta -from ipaddress import ip_address from typing import Any from async_upnp_client.exceptions import UpnpCommunicationError, UpnpConnectionError -import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription @@ -19,10 +16,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -30,7 +25,6 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( - CONF_LOCAL_IP, CONFIG_ENTRY_MAC_ADDRESS, CONFIG_ENTRY_ORIGINAL_UDN, CONFIG_ENTRY_ST, @@ -46,43 +40,15 @@ NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - vol.All( - cv.deprecated(CONF_LOCAL_IP), - { - vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), - }, - ) - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up UPnP component.""" - hass.data[DOMAIN] = {} - - # Only start if set up via configuration.yaml. - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.entry_id) + hass.data.setdefault(DOMAIN, {}) + udn = entry.data[CONFIG_ENTRY_UDN] st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name usn = f"{udn}::{st}" diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index b54098b6566..7d4e768e855 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -1,7 +1,6 @@ """Config flow for UPNP.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from typing import Any, cast @@ -9,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.components.ssdp import SsdpChange, SsdpServiceInfo +from homeassistant.components.ssdp import SsdpServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -21,7 +20,6 @@ from .const import ( CONFIG_ENTRY_UDN, DOMAIN, LOGGER, - SSDP_SEARCH_TIMEOUT, ST_IGD_V1, ST_IGD_V2, ) @@ -48,47 +46,6 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: ) -async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: - """Wait for a device to be discovered.""" - device_discovered_event = asyncio.Event() - - async def device_discovered(info: SsdpServiceInfo, change: SsdpChange) -> None: - if change != SsdpChange.BYEBYE: - LOGGER.debug( - "Device discovered: %s, at: %s", - info.ssdp_usn, - info.ssdp_location, - ) - device_discovered_event.set() - - cancel_discovered_callback_1 = await ssdp.async_register_callback( - hass, - device_discovered, - { - ssdp.ATTR_SSDP_ST: ST_IGD_V1, - }, - ) - cancel_discovered_callback_2 = await ssdp.async_register_callback( - hass, - device_discovered, - { - ssdp.ATTR_SSDP_ST: ST_IGD_V2, - }, - ) - - try: - await asyncio.wait_for( - device_discovered_event.wait(), timeout=SSDP_SEARCH_TIMEOUT - ) - except asyncio.TimeoutError: - return False - finally: - cancel_discovered_callback_1() - cancel_discovered_callback_2() - - return True - - async def _async_discover_igd_devices( hass: HomeAssistant, ) -> list[ssdp.SsdpServiceInfo]: @@ -106,6 +63,12 @@ async def _async_mac_address_from_discovery( return await async_get_mac_address_from_host(hass, host) +def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: + """Test if discovery is a complete IGD device.""" + root_device_info = discovery_info.upnp + return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} + + class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a UPnP/IGD config flow.""" @@ -120,7 +83,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the UPnP/IGD config flow.""" self._discoveries: list[SsdpServiceInfo] | None = None - async def async_step_user(self, user_input: Mapping | None = None) -> FlowResult: + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: """Handle a flow start.""" LOGGER.debug("async_step_user: user_input: %s", user_input) @@ -149,6 +114,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for discovery in discoveries if ( _is_complete_discovery(discovery) + and _is_igd_device(discovery) and discovery.ssdp_usn not in current_unique_ids ) ] @@ -172,42 +138,6 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, ) - async def async_step_import(self, import_info: Mapping | None) -> Mapping[str, Any]: - """Import a new UPnP/IGD device as a config entry. - - This flow is triggered by `async_setup`. If no device has been - configured before, find any device and create a config_entry for it. - Otherwise, do nothing. - """ - LOGGER.debug("async_step_import: import_info: %s", import_info) - - # Landed here via configuration.yaml entry. - # Any device already added, then abort. - if self._async_current_entries(): - LOGGER.debug("Already configured, aborting") - return self.async_abort(reason="already_configured") - - # Discover devices. - await _async_wait_for_discoveries(self.hass) - discoveries = await _async_discover_igd_devices(self.hass) - - # Ensure anything to add. If not, silently abort. - if not discoveries: - LOGGER.info("No UPnP devices discovered, aborting") - return self.async_abort(reason="no_devices_found") - - # Ensure complete discovery. - discovery = discoveries[0] - if not _is_complete_discovery(discovery): - LOGGER.debug("Incomplete discovery, ignoring") - return self.async_abort(reason="incomplete_discovery") - - # Ensure not already configuring/configured. - unique_id = discovery.ssdp_usn - await self.async_set_unique_id(unique_id) - - return await self._async_create_entry_from_discovery(discovery) - async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered UPnP/IGD device. @@ -221,6 +151,12 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") + # Ensure device is usable. Ideally we would use IgdDevice.is_profile_device, + # but that requires constructing the device completely. + if not _is_igd_device(discovery_info): + LOGGER.debug("Non IGD device, ignoring") + return self.async_abort(reason="non_igd_device") + # Ensure not already configuring/configured. unique_id = discovery_info.ssdp_usn await self.async_set_unique_id(unique_id) @@ -275,7 +211,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_ssdp_confirm() async def async_step_ssdp_confirm( - self, user_input: Mapping | None = None + self, user_input: Mapping[str, Any] | None = None ) -> FlowResult: """Confirm integration via SSDP.""" LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index dc87e73fdee..a4b913ec4c8 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.31.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.31.2", "getmac==0.8.2"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman", "@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/uptime/translations/es.json b/homeassistant/components/uptime/translations/es.json new file mode 100644 index 00000000000..84e840f453f --- /dev/null +++ b/homeassistant/components/uptime/translations/es.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "step": { + "user": { + "description": "\u00bfQuiere empezar a configurar?" + } + } + }, + "title": "Tiempo de funcionamiento" +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 5b6ac1d4880..14ec1ae6cdc 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -1,6 +1,7 @@ """Config flow for UptimeRobot integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pyuptimerobot import ( @@ -84,9 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Return the reauth confirm step.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/uptimerobot/translations/sensor.es.json b/homeassistant/components/uptimerobot/translations/sensor.es.json index 1f037738b42..2adb0ff18c5 100644 --- a/homeassistant/components/uptimerobot/translations/sensor.es.json +++ b/homeassistant/components/uptimerobot/translations/sensor.es.json @@ -1,8 +1,10 @@ { "state": { "uptimerobot__monitor_status": { + "down": "No disponible", "not_checked_yet": "No comprobado", "pause": "En pausa", + "seems_down": "Parece no disponible", "up": "Funcionante" } } diff --git a/homeassistant/components/uptimerobot/translations/sv.json b/homeassistant/components/uptimerobot/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index d26f97b295d..6e3eb9b2337 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -4,9 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from geojson_client.usgs_earthquake_hazards_program_feed import ( - UsgsEarthquakeHazardsProgramFeedManager, -) +from aio_geojson_usgs_earthquakes import UsgsEarthquakeHazardsProgramFeedManager import voluptuous as vol from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent @@ -21,10 +19,14 @@ from homeassistant.const import ( LENGTH_KILOMETERS, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -87,10 +89,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the USGS Earthquake Hazards Program Feed platform.""" @@ -103,21 +105,22 @@ def setup_platform( radius_in_km = config[CONF_RADIUS] minimum_magnitude = config[CONF_MINIMUM_MAGNITUDE] # Initialize the entity manager. - feed = UsgsEarthquakesFeedEntityManager( + manager = UsgsEarthquakesFeedEntityManager( hass, - add_entities, + async_add_entities, scan_interval, coordinates, feed_type, radius_in_km, minimum_magnitude, ) + await manager.async_init() - def start_feed_manager(event): + async def start_feed_manager(event=None): """Start feed manager.""" - feed.startup() + await manager.async_update() - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) class UsgsEarthquakesFeedEntityManager: @@ -126,7 +129,7 @@ class UsgsEarthquakesFeedEntityManager: def __init__( self, hass, - add_entities, + async_add_entities, scan_interval, coordinates, feed_type, @@ -136,7 +139,9 @@ class UsgsEarthquakesFeedEntityManager: """Initialize the Feed Entity Manager.""" self._hass = hass + websession = aiohttp_client.async_get_clientsession(hass) self._feed_manager = UsgsEarthquakeHazardsProgramFeedManager( + websession, self._generate_entity, self._update_entity, self._remove_entity, @@ -145,37 +150,42 @@ class UsgsEarthquakesFeedEntityManager: filter_radius=radius_in_km, filter_minimum_magnitude=minimum_magnitude, ) - self._add_entities = add_entities + self._async_add_entities = async_add_entities self._scan_interval = scan_interval - def startup(self): - """Start up this manager.""" - self._feed_manager.update() - self._init_regular_updates() + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" - def _init_regular_updates(self): - """Schedule regular updates at the specified interval.""" - track_time_interval( - self._hass, lambda now: self._feed_manager.update(), self._scan_interval - ) + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + async_track_time_interval(self._hass, update, self._scan_interval) + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") def get_entry(self, external_id): """Get feed entry by external id.""" return self._feed_manager.feed_entries.get(external_id) - def _generate_entity(self, external_id): + async def _generate_entity(self, external_id): """Generate new entity.""" new_entity = UsgsEarthquakesEvent(self, external_id) # Add new entities to HA. - self._add_entities([new_entity], True) + self._async_add_entities([new_entity], True) - def _update_entity(self, external_id): + async def _update_entity(self, external_id): """Update entity.""" - dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entity(self, external_id): + async def _remove_entity(self, external_id): """Remove entity.""" - dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class UsgsEarthquakesEvent(GeolocationEvent): diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json index 9c1f4566dc3..bd8ec9633bd 100644 --- a/homeassistant/components/usgs_earthquakes_feed/manifest.json +++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json @@ -2,8 +2,8 @@ "domain": "usgs_earthquakes_feed", "name": "U.S. Geological Survey Earthquake Hazards (USGS)", "documentation": "https://www.home-assistant.io/integrations/usgs_earthquakes_feed", - "requirements": ["geojson_client==0.6"], + "requirements": ["aio_geojson_usgs_earthquakes==0.1"], "codeowners": ["@exxamalte"], "iot_class": "cloud_polling", - "loggers": ["geojson_client"] + "loggers": ["aio_geojson_usgs_earthquakes"] } diff --git a/homeassistant/components/utility_meter/translations/es.json b/homeassistant/components/utility_meter/translations/es.json index bea05df125d..a38687741d8 100644 --- a/homeassistant/components/utility_meter/translations/es.json +++ b/homeassistant/components/utility_meter/translations/es.json @@ -5,14 +5,20 @@ "data": { "cycle": "Ciclo de reinicio del contador", "delta_values": "Valores delta", + "name": "Nombre", + "net_consumption": "Consumo neto", + "offset": "Compensaci\u00f3n de reinicio del medidor", "source": "Sensor de entrada", "tariffs": "Tarifas soportadas" }, "data_description": { + "delta_values": "Habilitar si los valores de origen son valores delta desde la \u00faltima lectura en lugar de valores absolutos.", "net_consumption": "Act\u00edvalo si es un contador limpio, es decir, puede aumentar y disminuir.", "offset": "Desplaza el d\u00eda de restablecimiento mensual del contador.", "tariffs": "Lista de tarifas admitidas, d\u00e9jala en blanco si utilizas una \u00fanica tarifa." - } + }, + "description": "Cree un sensor que rastree el consumo de varios servicios p\u00fablicos (p. ej., energ\u00eda, gas, agua, calefacci\u00f3n) durante un per\u00edodo de tiempo configurado, generalmente mensual. El sensor del medidor de servicios admite opcionalmente dividir el consumo por tarifas, en ese caso se crea un sensor para cada tarifa, as\u00ed como una entidad de selecci\u00f3n para elegir la tarifa actual.", + "title": "A\u00f1adir medidor de utilidades" } } }, diff --git a/homeassistant/components/vacuum/translations/nn.json b/homeassistant/components/vacuum/translations/nn.json index e06ae761458..12d981555ad 100644 --- a/homeassistant/components/vacuum/translations/nn.json +++ b/homeassistant/components/vacuum/translations/nn.json @@ -1,4 +1,12 @@ { + "device_automation": { + "condition_type": { + "is_cleaning": "k\u00f8yrer" + }, + "trigger_type": { + "cleaning": "starta reingjering" + } + }, "state": { "_": { "cleaning": "Reingjer", diff --git a/homeassistant/components/vacuum/translations/no.json b/homeassistant/components/vacuum/translations/no.json index 3d722c0927c..c467018b249 100644 --- a/homeassistant/components/vacuum/translations/no.json +++ b/homeassistant/components/vacuum/translations/no.json @@ -15,7 +15,7 @@ }, "state": { "_": { - "cleaning": "Rengj\u00f8ring", + "cleaning": "Rengj\u00f8r", "docked": "Dokket", "error": "Feil", "idle": "Inaktiv", diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 6872acbb5b7..4ba7d2d88fd 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -132,7 +132,7 @@ class ValloxFan(ValloxEntity, FanEntity): Returns true if the mode has been changed, false otherwise. """ try: - self._valid_preset_mode_or_raise(preset_mode) # type: ignore[no-untyped-call] + self._valid_preset_mode_or_raise(preset_mode) except NotValidPresetModeError as err: _LOGGER.error(err) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index d2aa9531467..9b5a52306d8 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.LIGHT, diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py new file mode 100644 index 00000000000..189cfb495e4 --- /dev/null +++ b/homeassistant/components/velbus/button.py @@ -0,0 +1,42 @@ +"""Support for Velbus Buttons.""" +from __future__ import annotations + +from velbusaio.channels import ( + Button as VelbusaioButton, + ButtonCounter as VelbusaioButtonCounter, +) + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import VelbusEntity +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] + cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + entities = [] + for channel in cntrl.get_all("button"): + entities.append(VelbusButton(channel)) + async_add_entities(entities) + + +class VelbusButton(VelbusEntity, ButtonEntity): + """Representation of a Velbus Binary Sensor.""" + + _channel: VelbusaioButton | VelbusaioButtonCounter + _attr_entity_registry_enabled_default = False + _attr_entity_category = EntityCategory.CONFIG + + async def async_press(self) -> None: + """Handle the button press.""" + await self._channel.press() diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index f759eea0a34..ec0c0f5f2d9 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,7 +2,7 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["velbus-aio==2022.5.1"], + "requirements": ["velbus-aio==2022.6.2"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "dependencies": ["usb"], diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 86e9a606d36..a0bd9b6c173 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -53,9 +53,9 @@ class VelbusSensor(VelbusEntity, SensorEntity): self._attr_name = f"{self._attr_name}-counter" # define the device class if self._is_counter: - self._attr_device_class = SensorDeviceClass.ENERGY - elif channel.is_counter_channel(): self._attr_device_class = SensorDeviceClass.POWER + elif channel.is_counter_channel(): + self._attr_device_class = SensorDeviceClass.ENERGY elif channel.is_temperature(): self._attr_device_class = SensorDeviceClass.TEMPERATURE # define the icon diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 26cccfce6ce..f721a628ef8 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -1,6 +1,8 @@ """Support for Velux covers.""" from __future__ import annotations +from typing import Any + from pyvlx import OpeningDevice, Position from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter, Window @@ -38,7 +40,7 @@ class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = ( CoverEntityFeature.OPEN @@ -56,19 +58,19 @@ class VeluxCover(VeluxEntity, CoverEntity): return supported_features @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of the cover.""" return 100 - self.node.position.position_percent @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" if isinstance(self.node, Blind): return 100 - self.node.orientation.position_percent return None @property - def device_class(self): + 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 @@ -85,19 +87,19 @@ class VeluxCover(VeluxEntity, CoverEntity): return CoverDeviceClass.WINDOW @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.node.position.closed - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.node.close(wait_for_completion=False) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.node.open(wait_for_completion=False) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position_percent = 100 - kwargs[ATTR_POSITION] @@ -105,23 +107,23 @@ class VeluxCover(VeluxEntity, CoverEntity): Position(position_percent=position_percent), wait_for_completion=False ) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.node.stop(wait_for_completion=False) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" await self.node.close_orientation(wait_for_completion=False) - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" await self.node.open_orientation(wait_for_completion=False) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" await self.node.stop_orientation(wait_for_completion=False) - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move cover tilt to a specific position.""" position_percent = 100 - kwargs[ATTR_TILT_POSITION] orientation = Position(position_percent=position_percent) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 4a5ea07dc82..d86b607cdc6 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -2,7 +2,7 @@ "domain": "velux", "name": "Velux", "documentation": "https://www.home-assistant.io/integrations/velux", - "requirements": ["pyvlx==0.2.19"], + "requirements": ["pyvlx==0.2.20"], "codeowners": ["@Julius2342"], "iot_class": "local_polling", "loggers": ["pyvlx"] diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 42a97020fa3..2f3331af6e2 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -3,7 +3,7 @@ "name": "Venstar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", - "requirements": ["venstarcolortouch==0.15"], + "requirements": ["venstarcolortouch==0.17"], "codeowners": ["@garbled1"], "iot_class": "local_polling", "loggers": ["venstarcolortouch"] diff --git a/homeassistant/components/venstar/translations/sv.json b/homeassistant/components/venstar/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/venstar/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index 319dcd031d0..c300f599faa 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -14,6 +14,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import entity_registry as er from .const import CONF_CONTROLLER, CONF_LEGACY_UNIQUE_ID, DOMAIN @@ -37,12 +38,14 @@ def list_to_str(data: list[Any]) -> str: return " ".join([str(i) for i in data]) -def new_options(lights: list[int], exclude: list[int]) -> dict: +def new_options(lights: list[int], exclude: list[int]) -> dict[str, list[int]]: """Create a standard options object.""" return {CONF_LIGHTS: lights, CONF_EXCLUDE: exclude} -def options_schema(options: Mapping[str, Any] = None) -> dict: +def options_schema( + options: Mapping[str, Any] | None = None +) -> dict[vol.Optional, type[str]]: """Return options schema.""" options = options or {} return { @@ -57,7 +60,7 @@ def options_schema(options: Mapping[str, Any] = None) -> dict: } -def options_data(user_input: dict) -> dict: +def options_data(user_input: dict[str, str]) -> dict[str, list[int]]: """Return options dict.""" return new_options( str_to_int_list(user_input.get(CONF_LIGHTS, "")), @@ -72,7 +75,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict = None): + async def async_step_init( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( @@ -95,7 +101,9 @@ class VeraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def async_step_user(self, user_input: dict = None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user initiated flow.""" if user_input is not None: return await self.async_step_finish( @@ -114,7 +122,7 @@ class VeraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, config: dict): + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Handle a flow initialized by import.""" # If there are entities with the legacy unique_id, then this imported config @@ -139,7 +147,7 @@ class VeraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def async_step_finish(self, config: dict): + async def async_step_finish(self, config: dict[str, Any]) -> FlowResult: """Validate and create config entry.""" base_url = config[CONF_CONTROLLER] = config[CONF_CONTROLLER].rstrip("/") controller = pv.VeraController(base_url) diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index 2f1a602ca19..5baf495b5fd 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -55,7 +55,7 @@ class VeraCover(VeraDevice[veraApi.VeraCurtain], CoverEntity): return 100 return position - def set_cover_position(self, **kwargs) -> None: + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.vera_device.set_level(kwargs.get(ATTR_POSITION)) self.schedule_update_ha_state() diff --git a/homeassistant/components/vera/translations/es.json b/homeassistant/components/vera/translations/es.json index 0cccacaa88f..8299b2ea6c9 100644 --- a/homeassistant/components/vera/translations/es.json +++ b/homeassistant/components/vera/translations/es.json @@ -9,6 +9,9 @@ "exclude": "Identificadores de dispositivos Vera a excluir de Home Assistant", "lights": "Identificadores de interruptores Vera que deben ser tratados como luces en Home Assistant", "vera_controller_url": "URL del controlador" + }, + "data_description": { + "vera_controller_url": "Deber\u00eda verse as\u00ed: http://192.168.1.161:3480" } } } diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 612d42bdf25..41687dbc6a4 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Verisure integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any, cast from verisure import ( @@ -108,7 +109,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Verisure.""" self.entry = cast( ConfigEntry, diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 8f9556643f8..8074cf28f32 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from typing import Any from verisure import Error as VerisureError @@ -118,11 +119,11 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return {"method": self.changed_method} - async def async_unlock(self, **kwargs) -> None: + async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" code = kwargs.get( ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) @@ -133,7 +134,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt await self.async_set_lock_state(code, STATE_UNLOCKED) - async def async_lock(self, **kwargs) -> None: + async def async_lock(self, **kwargs: Any) -> None: """Send lock command.""" code = kwargs.get( ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) diff --git a/homeassistant/components/verisure/translations/bg.json b/homeassistant/components/verisure/translations/bg.json new file mode 100644 index 00000000000..0f10e122185 --- /dev/null +++ b/homeassistant/components/verisure/translations/bg.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 1104a84e6b6..e11897ea9ae 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -20,6 +20,8 @@ async def async_process_devices(hass, manager): if manager.fans: devices[VS_FANS].extend(manager.fans) + # Expose fan sensors separately + devices[VS_SENSORS].extend(manager.fans) _LOGGER.info("%d VeSync fans found", len(manager.fans)) if manager.bulbs: @@ -28,7 +30,7 @@ async def async_process_devices(hass, manager): if manager.outlets: devices[VS_SWITCHES].extend(manager.outlets) - # Expose outlets' power & energy usage as separate sensors + # Expose outlets' voltage, power & energy usage as separate sensors devices[VS_SENSORS].extend(manager.outlets) _LOGGER.info("%d VeSync outlets found", len(manager.outlets)) @@ -49,31 +51,25 @@ class VeSyncBaseEntity(Entity): def __init__(self, device): """Initialize the VeSync device.""" self.device = device + self._attr_unique_id = self.base_unique_id + self._attr_name = self.base_name @property def base_unique_id(self): """Return the ID of this device.""" + # The unique_id property may be overridden in subclasses, such as in + # sensors. Maintaining base_unique_id allows us to group related + # entities under a single device. if isinstance(self.device.sub_device_no, int): return f"{self.device.cid}{str(self.device.sub_device_no)}" return self.device.cid - @property - def unique_id(self): - """Return the ID of this device.""" - # The unique_id property may be overridden in subclasses, such as in sensors. Maintaining base_unique_id allows - # us to group related entities under a single device. - return self.base_unique_id - @property def base_name(self): """Return the name of the device.""" + # Same story here as `base_unique_id` above return self.device.device_name - @property - def name(self): - """Return the name of the entity (may be overridden).""" - return self.base_name - @property def available(self) -> bool: """Return True if device is available.""" @@ -98,6 +94,11 @@ class VeSyncBaseEntity(Entity): class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): """Base class for VeSync Device Representations.""" + @property + def details(self): + """Provide access to the device details dictionary.""" + return self.device.details + @property def is_on(self): """Return True if device is on.""" diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index fceeff81ae4..b20a04b8a1c 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -9,3 +9,31 @@ VS_FANS = "fans" VS_LIGHTS = "lights" VS_SENSORS = "sensors" VS_MANAGER = "manager" + +DEV_TYPE_TO_HA = { + "wifi-switch-1.3": "outlet", + "ESW03-USA": "outlet", + "ESW01-EU": "outlet", + "ESW15-USA": "outlet", + "ESWL01": "switch", + "ESWL03": "switch", + "ESO15-TB": "outlet", +} + +SKU_TO_BASE_DEVICE = { + "LV-PUR131S": "LV-PUR131S", + "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S + "Core200S": "Core200S", + "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S + "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S + "Core300S": "Core300S", + "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S + "Core400S": "Core400S", + "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S + "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S + "LAP-C401S-WAAA": "Core400S", # Alt ID Model Core400S + "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 +} diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index f16a785ee1e..f89224aaba8 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -1,6 +1,9 @@ """Support for VeSync fans.""" +from __future__ import annotations + import logging import math +from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry @@ -14,26 +17,16 @@ from homeassistant.util.percentage import ( ) from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_FANS +from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) DEV_TYPE_TO_HA = { "LV-PUR131S": "fan", - "LV-RH131S": "fan", # Alt ID Model LV-PUR131S "Core200S": "fan", - "LAP-C201S-AUSR": "fan", # Alt ID Model Core200S - "LAP-C202S-WUSR": "fan", # Alt ID Model Core200S "Core300S": "fan", - "LAP-C301S-WJP": "fan", # Alt ID Model Core300S "Core400S": "fan", - "LAP-C401S-WJP": "fan", # Alt ID Model Core400S - "LAP-C401S-WUSR": "fan", # Alt ID Model Core400S - "LAP-C401S-WAAA": "fan", # Alt ID Model Core400S "Core600S": "fan", - "LAP-C601S-WUS": "fan", # Alt ID Model Core600S - "LAP-C601S-WUSR": "fan", # Alt ID Model Core600S - "LAP-C601S-WEU": "fan", # Alt ID Model Core600S } FAN_MODE_AUTO = "auto" @@ -41,37 +34,17 @@ FAN_MODE_SLEEP = "sleep" PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LV-RH131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model LV-PUR131S "Core200S": [FAN_MODE_SLEEP], - "LAP-C201S-AUSR": [FAN_MODE_SLEEP], # Alt ID Model Core200S - "LAP-C202S-WUSR": [FAN_MODE_SLEEP], # Alt ID Model Core200S "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C301S-WJP": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core300S "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C401S-WJP": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S - "LAP-C401S-WUSR": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S - "LAP-C401S-WAAA": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C601S-WUS": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S - "LAP-C601S-WUSR": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S - "LAP-C601S-WEU": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), - "LV-RH131S": (1, 3), # ALt ID Model LV-PUR131S "Core200S": (1, 3), - "LAP-C201S-AUSR": (1, 3), # ALt ID Model Core200S - "LAP-C202S-WUSR": (1, 3), # ALt ID Model Core200S "Core300S": (1, 3), - "LAP-C301S-WJP": (1, 3), # ALt ID Model Core300S "Core400S": (1, 4), - "LAP-C401S-WJP": (1, 4), # ALt ID Model Core400S - "LAP-C401S-WUSR": (1, 4), # ALt ID Model Core400S - "LAP-C401S-WAAA": (1, 4), # ALt ID Model Core400S "Core600S": (1, 4), - "LAP-C601S-WUS": (1, 4), # ALt ID Model Core600S - "LAP-C601S-WUSR": (1, 4), # ALt ID Model Core600S - "LAP-C601S-WEU": (1, 4), # ALt ID Model Core600S } @@ -99,7 +72,7 @@ def _setup_entities(devices, async_add_entities): """Check if device is online and add entity.""" entities = [] for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) == "fan": + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type)) == "fan": entities.append(VeSyncFanHA(dev)) else: _LOGGER.warning( @@ -121,29 +94,31 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): self.smartfan = fan @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed.""" if ( self.smartfan.mode == "manual" and (current_level := self.smartfan.fan_level) is not None ): return ranged_value_to_percentage( - SPEED_RANGE[self.device.device_type], current_level + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level ) return None @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE[self.device.device_type]) + return int_states_in_range( + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]] + ) @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" - return PRESET_MODES[self.device.device_type] + return PRESET_MODES[SKU_TO_BASE_DEVICE[self.device.device_type]] @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the current preset mode.""" if self.smartfan.mode in (FAN_MODE_AUTO, FAN_MODE_SLEEP): return self.smartfan.mode @@ -155,7 +130,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): return self.smartfan.uuid @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" attr = {} @@ -171,18 +146,12 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): if hasattr(self.smartfan, "night_light"): attr["night_light"] = self.smartfan.night_light - if self.smartfan.details.get("air_quality_value") is not None: - attr["air_quality"] = self.smartfan.details["air_quality_value"] - if hasattr(self.smartfan, "mode"): attr["mode"] = self.smartfan.mode - if hasattr(self.smartfan, "filter_life"): - attr["filter_life"] = self.smartfan.filter_life - return attr - def set_percentage(self, percentage): + def set_percentage(self, percentage: int) -> None: """Set the speed of the device.""" if percentage == 0: self.smartfan.turn_off() @@ -195,13 +164,13 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): self.smartfan.change_fan_speed( math.ceil( percentage_to_ranged_value( - SPEED_RANGE[self.device.device_type], percentage + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage ) ) ) self.schedule_update_ha_state() - def set_preset_mode(self, preset_mode): + def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of device.""" if preset_mode not in self.preset_modes: raise ValueError( @@ -221,9 +190,9 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): def turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn the device on.""" if preset_mode: diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index cc69bf36fa6..2da6d8ea6b7 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -1,25 +1,170 @@ -"""Support for power & energy sensors for VeSync outlets.""" +"""Support for voltage, power & energy sensors for VeSync outlets.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncSwitch + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .common import VeSyncBaseEntity -from .const import DOMAIN, VS_DISCOVERY, VS_SENSORS -from .switch import DEV_TYPE_TO_HA +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS _LOGGER = logging.getLogger(__name__) +@dataclass +class VeSyncSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] + + +@dataclass +class VeSyncSensorEntityDescription( + SensorEntityDescription, VeSyncSensorEntityDescriptionMixin +): + """Describe VeSync sensor entity.""" + + exists_fn: Callable[ + [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool + ] = lambda _: True + update_fn: Callable[ + [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None + ] = lambda _: None + + +def update_energy(device): + """Update outlet details and energy usage.""" + device.update() + device.update_energy() + + +def sku_supported(device, supported): + """Get the base device of which a device is an instance.""" + return SKU_TO_BASE_DEVICE.get(device.device_type) in supported + + +def ha_dev_type(device): + """Get the homeassistant device_type for a given device.""" + return DEV_TYPE_TO_HA.get(device.device_type) + + +FILTER_LIFE_SUPPORTED = ["LV-PUR131S", "Core200S", "Core300S", "Core400S", "Core600S"] +AIR_QUALITY_SUPPORTED = ["LV-PUR131S", "Core400S", "Core600S"] +PM25_SUPPORTED = ["Core400S", "Core600S"] + +SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( + VeSyncSensorEntityDescription( + key="filter-life", + name="Filter Life", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.details["filter_life"], + exists_fn=lambda device: sku_supported(device, FILTER_LIFE_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="air-quality", + name="Air Quality", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["air_quality"], + exists_fn=lambda device: sku_supported(device, AIR_QUALITY_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="pm25", + name="PM2.5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["air_quality_value"], + exists_fn=lambda device: sku_supported(device, PM25_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="power", + name="current power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["power"], + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy", + name="energy use today", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.energy_today, + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy-weekly", + name="energy use weekly", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.weekly_energy_total, + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy-monthly", + name="energy use monthly", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.monthly_energy_total, + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy-yearly", + name="energy use yearly", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.yearly_energy_total, + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="voltage", + name="current voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["voltage"], + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -44,107 +189,33 @@ def _setup_entities(devices, async_add_entities): """Check if device is online and add entity.""" entities = [] for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) != "outlet": - # Not an outlet that supports energy/power, so do not create sensor entities - continue - entities.append(VeSyncPowerSensor(dev)) - entities.append(VeSyncEnergySensor(dev)) - + for description in SENSORS: + if description.exists_fn(dev): + entities.append(VeSyncSensorEntity(dev, description)) async_add_entities(entities, update_before_add=True) class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): - """Representation of a sensor describing diagnostics of a VeSync outlet.""" + """Representation of a sensor describing a VeSync device.""" - def __init__(self, plug): + entity_description: VeSyncSensorEntityDescription + + def __init__( + self, + device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch, + description: VeSyncSensorEntityDescription, + ) -> None: """Initialize the VeSync outlet device.""" - super().__init__(plug) - self.smartplug = plug + super().__init__(device) + self.entity_description = description + self._attr_name = f"{super().name} {description.name}" + self._attr_unique_id = f"{super().unique_id}-{description.key}" @property - def entity_category(self): - """Return the diagnostic entity category.""" - return EntityCategory.DIAGNOSTIC + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) - -class VeSyncPowerSensor(VeSyncSensorEntity): - """Representation of current power use for a VeSync outlet.""" - - @property - def unique_id(self): - """Return unique ID for power sensor on device.""" - return f"{super().unique_id}-power" - - @property - def name(self): - """Return sensor name.""" - return f"{super().name} current power" - - @property - def device_class(self): - """Return the power device class.""" - return SensorDeviceClass.POWER - - @property - def native_value(self): - """Return the current power usage in W.""" - return self.smartplug.power - - @property - def native_unit_of_measurement(self): - """Return the Watt unit of measurement.""" - return POWER_WATT - - @property - def state_class(self): - """Return the measurement state class.""" - return SensorStateClass.MEASUREMENT - - def update(self): - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() - - -class VeSyncEnergySensor(VeSyncSensorEntity): - """Representation of current day's energy use for a VeSync outlet.""" - - def __init__(self, plug): - """Initialize the VeSync outlet device.""" - super().__init__(plug) - self.smartplug = plug - - @property - def unique_id(self): - """Return unique ID for power sensor on device.""" - return f"{super().unique_id}-energy" - - @property - def name(self): - """Return sensor name.""" - return f"{super().name} energy use today" - - @property - def device_class(self): - """Return the energy device class.""" - return SensorDeviceClass.ENERGY - - @property - def native_value(self): - """Return the today total energy usage in kWh.""" - return self.smartplug.energy_today - - @property - def native_unit_of_measurement(self): - """Return the kWh unit of measurement.""" - return ENERGY_KILO_WATT_HOUR - - @property - def state_class(self): - """Return the total_increasing state class.""" - return SensorStateClass.TOTAL_INCREASING - - def update(self): - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() + def update(self) -> None: + """Run the update function defined for the sensor.""" + return self.entity_description.update_fn(self.device) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 282f8d99817..68b10e40bcb 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -8,20 +8,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_SWITCHES +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_SWITCHES _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "wifi-switch-1.3": "outlet", - "ESW03-USA": "outlet", - "ESW01-EU": "outlet", - "ESW15-USA": "outlet", - "ESWL01": "switch", - "ESWL03": "switch", - "ESO15-TB": "outlet", -} - async def async_setup_entry( hass: HomeAssistant, @@ -76,18 +66,6 @@ class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): super().__init__(plug) self.smartplug = plug - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - if not hasattr(self.smartplug, "weekly_energy_total"): - return {} - return { - "voltage": self.smartplug.voltage, - "weekly_energy_total": self.smartplug.weekly_energy_total, - "monthly_energy_total": self.smartplug.monthly_energy_total, - "yearly_energy_total": self.smartplug.yearly_energy_total, - } - def update(self): """Update outlet details and energy usage.""" self.smartplug.update() diff --git a/homeassistant/components/vesync/translations/bg.json b/homeassistant/components/vesync/translations/bg.json index bb496b3422a..c435a669d5a 100644 --- a/homeassistant/components/vesync/translations/bg.json +++ b/homeassistant/components/vesync/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/vesync/translations/sv.json b/homeassistant/components/vesync/translations/sv.json index b9eedc2f747..4621636cecc 100644 --- a/homeassistant/components/vesync/translations/sv.json +++ b/homeassistant/components/vesync/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 3fdca6653d0..95eeb154f9c 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by ViaggiaTreno Data" VIAGGIATRENO_ENDPOINT = ( - "http://www.viaggiatreno.it/viaggiatrenonew/" + "http://www.viaggiatreno.it/infomobilita/" "resteasy/viaggiatreno/andamentoTreno/" "{station_id}/{train_id}/{timestamp}" ) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 01cfff59357..3f54e5bd7e7 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -198,15 +199,15 @@ class ViCareBinarySensor(BinarySensorEntity): self._state = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + 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 available(self): @@ -214,7 +215,7 @@ class ViCareBinarySensor(BinarySensorEntity): return self._state is not None @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for this device.""" tmp_id = ( f"{self._device_config.getConfig().serial}-{self.entity_description.key}" diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index e1d6bc4223c..b691c01796b 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -15,7 +15,7 @@ import requests from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -94,18 +94,18 @@ class ViCareButton(ButtonEntity): _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + 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): + def unique_id(self) -> str: """Return unique ID for this device.""" tmp_id = ( f"{self._device_config.getConfig().serial}-{self.entity_description.key}" diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 4773101f1b9..8f00f9e6c3b 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -31,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -71,19 +72,13 @@ VICARE_TEMP_HEATING_MIN = 3 VICARE_TEMP_HEATING_MAX = 37 VICARE_TO_HA_HVAC_HEATING = { - VICARE_MODE_DHW: HVACMode.OFF, - VICARE_MODE_HEATING: HVACMode.HEAT, - VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, - VICARE_MODE_DHWANDHEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_FORCEDREDUCED: HVACMode.OFF, - VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, VICARE_MODE_OFF: HVACMode.OFF, -} - -HA_TO_VICARE_HVAC_HEATING = { - HVACMode.HEAT: VICARE_MODE_FORCEDNORMAL, - HVACMode.OFF: VICARE_MODE_FORCEDREDUCED, - HVACMode.AUTO: VICARE_MODE_DHWANDHEATING, + VICARE_MODE_DHW: HVACMode.OFF, + VICARE_MODE_DHWANDHEATINGCOOLING: HVACMode.AUTO, + VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, + VICARE_MODE_HEATING: HVACMode.AUTO, + VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, } VICARE_TO_HA_PRESET_HEATING = { @@ -167,20 +162,20 @@ class ViCareClimate(ClimateEntity): self._current_action = None @property - def unique_id(self): + 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): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + 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/", + ) def update(self): """Let HA know there has been an update from the ViCare API.""" @@ -276,19 +271,41 @@ class ViCareClimate(ClimateEntity): def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set a new hvac mode on the ViCare API.""" - vicare_mode = HA_TO_VICARE_HVAC_HEATING.get(hvac_mode) + if "vicare_modes" not in self._attributes: + raise ValueError("Cannot set hvac mode when vicare_modes are not known") + + vicare_mode = self.vicare_mode_from_hvac_mode(hvac_mode) if vicare_mode is None: - raise ValueError( - f"Cannot set invalid vicare mode: {hvac_mode} / {vicare_mode}" - ) + raise ValueError(f"Cannot set invalid hvac mode: {hvac_mode}") _LOGGER.debug("Setting hvac mode to %s / %s", hvac_mode, vicare_mode) self._circuit.setMode(vicare_mode) + def vicare_mode_from_hvac_mode(self, hvac_mode): + """Return the corresponding vicare mode for an hvac_mode.""" + if "vicare_modes" not in self._attributes: + return None + + supported_modes = self._attributes["vicare_modes"] + for key, value in VICARE_TO_HA_HVAC_HEATING.items(): + if key in supported_modes and value == hvac_mode: + return key + return None + @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac modes.""" - return list(HA_TO_VICARE_HVAC_HEATING) + if "vicare_modes" not in self._attributes: + return [] + + supported_modes = self._attributes["vicare_modes"] + hvac_modes = [] + for key, value in VICARE_TO_HA_HVAC_HEATING.items(): + if value in hvac_modes: + continue + if key in supported_modes: + hvac_modes.append(value) + return hvac_modes @property def hvac_action(self) -> HVACAction: diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 60a39b454a2..e1deef0df00 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -30,6 +30,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -77,6 +78,22 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="boiler_supply_temperature", + name="Boiler Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getBoilerCommonSupplyTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="hotwater_out_temperature", + name="Hot Water Out Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterOutletTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_today", name="Hot water gas consumption today", @@ -581,15 +598,15 @@ class ViCareSensor(SensorEntity): self._state = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + 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 available(self): @@ -597,7 +614,7 @@ class ViCareSensor(SensorEntity): return self._state is not None @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for this device.""" tmp_id = ( f"{self._device_config.getConfig().serial}-{self.entity_description.key}" diff --git a/homeassistant/components/vicare/translations/sv.json b/homeassistant/components/vicare/translations/sv.json new file mode 100644 index 00000000000..80588906063 --- /dev/null +++ b/homeassistant/components/vicare/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "client_id": "API-nyckel", + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 6f9262200ec..ae8456cac6f 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -21,6 +21,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -140,20 +141,20 @@ class ViCareWater(WaterHeaterEntity): _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) @property - def unique_id(self): + 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): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + 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): diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 8acc602dd36..a80105579fe 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -187,11 +187,11 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize config flow.""" self._user_schema = None - self._must_show_form = None + self._must_show_form: bool | None = None self._ch_type = None self._pairing_token = None - self._data = None - self._apps = {} + self._data: dict[str, Any] | None = None + self._apps: dict[str, list] = {} async def _create_entry(self, input_dict: dict[str, Any]) -> FlowResult: """Create vizio config entry.""" @@ -387,10 +387,11 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Ask user for PIN to complete pairing process. """ - errors = {} + errors: dict[str, str] = {} # Start pairing process if it hasn't already started if not self._ch_type and not self._pairing_token: + assert self._data dev = VizioAsync( DEVICE_ID, self._data[CONF_HOST], @@ -448,6 +449,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _pairing_complete(self, step_id: str) -> FlowResult: """Handle config flow completion.""" + assert self._data if not self._must_show_form: return await self._create_entry(self._data) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index b2d33551020..ab48f1405a9 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any from pyvizio import VizioAsync from pyvizio.api.apps import find_app_name @@ -103,7 +102,10 @@ async def async_setup_entry( params["data"] = new_data if params: - hass.config_entries.async_update_entry(config_entry, **params) + hass.config_entries.async_update_entry( + config_entry, + **params, # type: ignore[arg-type] + ) device = VizioAsync( DEVICE_ID, @@ -134,7 +136,7 @@ class VizioDevice(MediaPlayerEntity): config_entry: ConfigEntry, device: VizioAsync, name: str, - device_class: str, + device_class: MediaPlayerDeviceClass, apps_coordinator: DataUpdateCoordinator, ) -> None: """Initialize Vizio device.""" @@ -145,8 +147,8 @@ class VizioDevice(MediaPlayerEntity): self._current_input = None self._current_app_config = None self._attr_app_name = None - self._available_inputs = [] - self._available_apps = [] + self._available_inputs: list[str] = [] + self._available_apps: list[str] = [] self._all_apps = apps_coordinator.data if apps_coordinator else None self._conf_apps = config_entry.options.get(CONF_APPS, {}) self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get( @@ -195,6 +197,7 @@ class VizioDevice(MediaPlayerEntity): self._attr_available = True if not self._attr_device_info: + assert self._attr_unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="VIZIO", @@ -276,7 +279,7 @@ class VizioDevice(MediaPlayerEntity): if self._attr_app_name == NO_APP_RUNNING: self._attr_app_name = None - def _get_additional_app_names(self) -> list[dict[str, Any]]: + def _get_additional_app_names(self) -> list[str]: """Return list of additional apps that were included in configuration.yaml.""" return [ additional_app["name"] for additional_app in self._additional_app_configs diff --git a/homeassistant/components/vlc_telnet/config_flow.py b/homeassistant/components/vlc_telnet/config_flow.py index 29508ad1120..35898e91b34 100644 --- a/homeassistant/components/vlc_telnet/config_flow.py +++ b/homeassistant/components/vlc_telnet/config_flow.py @@ -1,6 +1,7 @@ """Config flow for VLC media player Telnet integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -104,7 +105,7 @@ class VLCTelnetConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_form_schema(user_input), errors=errors ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth flow.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert self.entry diff --git a/homeassistant/components/vlc_telnet/translations/sv.json b/homeassistant/components/vlc_telnet/translations/sv.json new file mode 100644 index 00000000000..eba844f6c03 --- /dev/null +++ b/homeassistant/components/vlc_telnet/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index 23f80a4fae5..c341627eef4 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -1,6 +1,10 @@ """Support for Volvo On Call locks.""" from __future__ import annotations +from typing import Any + +from volvooncall.dashboard import Lock + from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,15 +29,17 @@ async def async_setup_platform( class VolvoLock(VolvoEntity, LockEntity): """Represents a car lock.""" + instrument: Lock + @property - def is_locked(self): + def is_locked(self) -> bool | None: """Return true if lock is locked.""" return self.instrument.is_locked - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the car.""" await self.instrument.lock() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" await self.instrument.unlock() diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py index 09acb13ea27..f1e1c13871c 100644 --- a/homeassistant/components/vulcan/config_flow.py +++ b/homeassistant/components/vulcan/config_flow.py @@ -1,5 +1,7 @@ """Adds config flow for Vulcan.""" +from collections.abc import Mapping import logging +from typing import Any from aiohttp import ClientConnectionError import voluptuous as vol @@ -16,6 +18,7 @@ from vulcan import ( from homeassistant import config_entries from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import DOMAIN @@ -236,7 +239,7 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input=None): + 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() diff --git a/homeassistant/components/vulcan/translations/bg.json b/homeassistant/components/vulcan/translations/bg.json index 187ad8cff4b..f99cd3cca14 100644 --- a/homeassistant/components/vulcan/translations/bg.json +++ b/homeassistant/components/vulcan/translations/bg.json @@ -4,7 +4,8 @@ "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { - "cannot_connect": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e - \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0432\u0440\u044a\u0437\u043a\u0430" + "cannot_connect": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e - \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0432\u0440\u044a\u0437\u043a\u0430", + "unknown": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "select_saved_credentials": { diff --git a/homeassistant/components/vulcan/translations/es.json b/homeassistant/components/vulcan/translations/es.json index a92a8878730..7538c6411df 100644 --- a/homeassistant/components/vulcan/translations/es.json +++ b/homeassistant/components/vulcan/translations/es.json @@ -1,29 +1,54 @@ { "config": { "abort": { + "all_student_already_configured": "Ya se han a\u00f1adido todos los estudiantes.", "already_configured": "Ya se ha a\u00f1adido a este alumno.", + "no_matching_entries": "No se encontraron entradas que coincidan, use una cuenta diferente o elimine la integraci\u00f3n con el estudiante obsoleto.", "reauth_successful": "Re-autenticaci\u00f3n exitosa" }, "error": { + "cannot_connect": "Error de conexi\u00f3n - compruebe su conexi\u00f3n a Internet", + "expired_credentials": "Credenciales caducadas - cree nuevas en la p\u00e1gina de registro de la aplicaci\u00f3n m\u00f3vil de Vulcan", "expired_token": "Token caducado, genera un nuevo token", + "invalid_pin": "Pin no v\u00e1lido", "invalid_symbol": "S\u00edmbolo inv\u00e1lido", - "invalid_token": "Token inv\u00e1lido" + "invalid_token": "Token inv\u00e1lido", + "unknown": "Se produjo un error desconocido" }, "step": { + "add_next_config_entry": { + "data": { + "use_saved_credentials": "Usar credenciales guardadas" + }, + "description": "A\u00f1adir otro estudiante." + }, "auth": { "data": { - "pin": "PIN" - } + "pin": "PIN", + "region": "S\u00edmbolo", + "token": "Token" + }, + "description": "Acceda a su cuenta de Vulcan a trav\u00e9s de la p\u00e1gina de registro de la aplicaci\u00f3n m\u00f3vil." }, "reauth_confirm": { "data": { - "region": "S\u00edmbolo" - } + "pin": "Pin", + "region": "S\u00edmbolo", + "token": "Token" + }, + "description": "Acceda a su cuenta de Vulcan a trav\u00e9s de la p\u00e1gina de registro de la aplicaci\u00f3n m\u00f3vil." + }, + "select_saved_credentials": { + "data": { + "credentials": "Inicio de sesi\u00f3n" + }, + "description": "Seleccione las credenciales guardadas." }, "select_student": { "data": { "student_name": "Selecciona al alumno" - } + }, + "description": "Seleccione el estudiante, puede a\u00f1adir m\u00e1s estudiantes a\u00f1adiendo de nuevo la integraci\u00f3n." } } } diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 332a1ee6741..ae003d84a9c 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -70,7 +70,7 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 195: ChargerStatus.CHARGING, 196: ChargerStatus.DISCHARGING, 209: ChargerStatus.LOCKED, - 210: ChargerStatus.LOCKED, + 210: ChargerStatus.LOCKED_CAR_CONNECTED, } diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index d2c0a048fa1..85f5d02ba99 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Wallbox integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -47,9 +48,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=COMPONENT_DOMAIN): """Start the Wallbox config flow.""" self._reauth_entry: config_entries.ConfigEntry | None = None - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + 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"] diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 6152207427b..0e4e1477911 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -41,6 +41,7 @@ class ChargerStatus(StrEnum): ERROR = "Error" READY = "Ready" LOCKED = "Locked" + LOCKED_CAR_CONNECTED = "Locked, car connected" UPDATING = "Updating" WAITING_IN_QUEUE_POWER_SHARING = "Waiting in queue by Power Sharing" WAITING_IN_QUEUE_POWER_BOOST = "Waiting in queue by Power Boost" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 1ad06145ed5..1db791fd389 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -28,7 +28,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, name="Max. Charging Current", - min_value=6, + native_min_value=6, ), } @@ -74,17 +74,17 @@ class WallboxNumber(WallboxEntity, NumberEntity): self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum available current.""" return cast(float, self._coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY]) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" return cast( Optional[float], self._coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] ) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the value of the entity.""" await self._coordinator.async_set_charging_current(value) diff --git a/homeassistant/components/wallbox/translations/sv.json b/homeassistant/components/wallbox/translations/sv.json new file mode 100644 index 00000000000..8a60ea1a5dc --- /dev/null +++ b/homeassistant/components/wallbox/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/water_heater/translations/sv.json b/homeassistant/components/water_heater/translations/sv.json index 37de0012a79..cb4826c461a 100644 --- a/homeassistant/components/water_heater/translations/sv.json +++ b/homeassistant/components/water_heater/translations/sv.json @@ -1,7 +1,8 @@ { "state": { "_": { - "heat_pump": "V\u00e4rmepump" + "heat_pump": "V\u00e4rmepump", + "off": "Av" } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index 993e070ffe8..a5d9c6925c8 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -1,6 +1,7 @@ """Config flow for WattTime integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from aiowatttime import Client @@ -189,9 +190,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_coordinates() - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._data = {**config} + self._data = {**entry_data} return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/watttime/translations/sv.json b/homeassistant/components/watttime/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/watttime/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/waze_travel_time/translations/bg.json b/homeassistant/components/waze_travel_time/translations/bg.json index 35cfa0ad1d7..f7d35259c93 100644 --- a/homeassistant/components/waze_travel_time/translations/bg.json +++ b/homeassistant/components/waze_travel_time/translations/bg.json @@ -3,7 +3,8 @@ "step": { "user": { "data": { - "name": "\u0418\u043c\u0435" + "name": "\u0418\u043c\u0435", + "region": "\u0420\u0435\u0433\u0438\u043e\u043d" } } } diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 2e0f8912867..1fdb9173646 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,22 +1,49 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations +from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass from datetime import timedelta +import inspect import logging -from typing import Final, TypedDict, final +from typing import Any, Final, TypedDict, final from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, TEMP_CELSIUS -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_MILLIMETERS, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + PRESSURE_HPA, + PRESSURE_INHG, + PRESSURE_MBAR, + PRESSURE_MMHG, + SPEED_FEET_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType +from homeassistant.util import ( + distance as distance_util, + pressure as pressure_util, + speed as speed_util, + temperature as temperature_util, +) # mypy: allow-untyped-defs, no-check-untyped-defs @@ -40,21 +67,31 @@ ATTR_CONDITION_WINDY = "windy" ATTR_CONDITION_WINDY_VARIANT = "windy-variant" ATTR_FORECAST = "forecast" ATTR_FORECAST_CONDITION: Final = "condition" +ATTR_FORECAST_NATIVE_PRECIPITATION: Final = "native_precipitation" ATTR_FORECAST_PRECIPITATION: Final = "precipitation" ATTR_FORECAST_PRECIPITATION_PROBABILITY: Final = "precipitation_probability" +ATTR_FORECAST_NATIVE_PRESSURE: Final = "native_pressure" ATTR_FORECAST_PRESSURE: Final = "pressure" +ATTR_FORECAST_NATIVE_TEMP: Final = "native_temperature" ATTR_FORECAST_TEMP: Final = "temperature" +ATTR_FORECAST_NATIVE_TEMP_LOW: Final = "native_templow" ATTR_FORECAST_TEMP_LOW: Final = "templow" ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" +ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" ATTR_WEATHER_HUMIDITY = "humidity" ATTR_WEATHER_OZONE = "ozone" ATTR_WEATHER_PRESSURE = "pressure" +ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" ATTR_WEATHER_TEMPERATURE = "temperature" +ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" ATTR_WEATHER_VISIBILITY = "visibility" +ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" ATTR_WEATHER_WIND_BEARING = "wind_bearing" ATTR_WEATHER_WIND_SPEED = "wind_speed" +ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" +ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" DOMAIN = "weather" @@ -64,18 +101,85 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 +VALID_UNITS_PRESSURE: tuple[str, ...] = ( + PRESSURE_HPA, + PRESSURE_MBAR, + PRESSURE_INHG, + PRESSURE_MMHG, +) +VALID_UNITS_TEMPERATURE: tuple[str, ...] = ( + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +VALID_UNITS_PRECIPITATION: tuple[str, ...] = ( + LENGTH_MILLIMETERS, + LENGTH_INCHES, +) +VALID_UNITS_VISIBILITY: tuple[str, ...] = ( + LENGTH_KILOMETERS, + LENGTH_MILES, +) +VALID_UNITS_WIND_SPEED: tuple[str, ...] = ( + SPEED_FEET_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, +) + +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + ATTR_WEATHER_PRESSURE_UNIT: pressure_util.convert, + ATTR_WEATHER_TEMPERATURE_UNIT: temperature_util.convert, + ATTR_WEATHER_VISIBILITY_UNIT: distance_util.convert, + ATTR_WEATHER_PRECIPITATION_UNIT: distance_util.convert, + ATTR_WEATHER_WIND_SPEED_UNIT: speed_util.convert, +} + +VALID_UNITS: dict[str, tuple[str, ...]] = { + ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, + ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, + ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, + ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, + ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, +} + + +def round_temperature(temperature: float | None, precision: float) -> float | None: + """Convert temperature into preferred precision for display.""" + if temperature is None: + return None + + # Round in the units appropriate + if precision == PRECISION_HALVES: + temperature = round(temperature * 2) / 2.0 + elif precision == PRECISION_TENTHS: + temperature = round(temperature, 1) + # Integer as a fall back (PRECISION_WHOLE) + else: + temperature = round(temperature) + + return temperature + class Forecast(TypedDict, total=False): - """Typed weather forecast dict.""" + """Typed weather forecast dict. + + All attributes are in native units and old attributes kept for backwards compatibility. + """ condition: str | None datetime: str precipitation_probability: int | None + native_precipitation: float | None precipitation: float | None + native_pressure: float | None pressure: float | None + native_temperature: float | None temperature: float | None + native_templow: float | None templow: float | None wind_bearing: float | str | None + native_wind_speed: float | None wind_speed: float | None @@ -114,38 +218,219 @@ class WeatherEntity(Entity): _attr_humidity: float | None = None _attr_ozone: float | None = None _attr_precision: float - _attr_pressure: float | None = None - _attr_pressure_unit: str | None = None + _attr_pressure: float | None = ( + None # Provide backwards compatibility. Use _attr_native_pressure + ) + _attr_pressure_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_pressure_unit + ) _attr_state: None = None - _attr_temperature_unit: str - _attr_temperature: float | None - _attr_visibility: float | None = None - _attr_visibility_unit: str | None = None - _attr_precipitation_unit: str | None = None + _attr_temperature: float | None = ( + None # Provide backwards compatibility. Use _attr_native_temperature + ) + _attr_temperature_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_temperature_unit + ) + _attr_visibility: float | None = ( + None # Provide backwards compatibility. Use _attr_native_visibility + ) + _attr_visibility_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_visibility_unit + ) + _attr_precipitation_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_precipitation_unit + ) _attr_wind_bearing: float | str | None = None - _attr_wind_speed: float | None = None - _attr_wind_speed_unit: str | None = None + _attr_wind_speed: float | None = ( + None # Provide backwards compatibility. Use _attr_native_wind_speed + ) + _attr_wind_speed_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_wind_speed_unit + ) + + _attr_native_pressure: float | None = None + _attr_native_pressure_unit: str | None = None + _attr_native_temperature: float | None = None + _attr_native_temperature_unit: str | None = None + _attr_native_visibility: float | None = None + _attr_native_visibility_unit: str | None = None + _attr_native_precipitation_unit: str | None = None + _attr_native_wind_speed: float | None = None + _attr_native_wind_speed_unit: str | None = None + + _weather_option_temperature_unit: str | None = None + _weather_option_pressure_unit: str | None = None + _weather_option_visibility_unit: str | None = None + _weather_option_precipitation_unit: str | None = None + _weather_option_wind_speed_unit: str | None = None + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + _reported = False + if any( + method in cls.__dict__ + for method in ( + "_attr_temperature", + "temperature", + "_attr_temperature_unit", + "temperature_unit", + "_attr_pressure", + "pressure", + "_attr_pressure_unit", + "pressure_unit", + "_attr_wind_speed", + "wind_speed", + "_attr_wind_speed_unit", + "wind_speed_unit", + "_attr_visibility", + "visibility", + "_attr_visibility_unit", + "visibility_unit", + "_attr_precipitation_unit", + "precipitation_unit", + ) + ): + if _reported is False: + module = inspect.getmodule(cls) + _reported = True + if ( + module + and module.__file__ + and "custom_components" in module.__file__ + ): + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s::%s is overriding deprecated methods on an instance of " + "WeatherEntity, this is not valid and will be unsupported " + "from Home Assistant 2023.1. Please %s", + cls.__module__, + cls.__name__, + report_issue, + ) + + async def async_internal_added_to_hass(self) -> None: + """Call when the sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self.async_registry_entry_updated() @property def temperature(self) -> float | None: - """Return the platform temperature in native units (i.e. not converted).""" + """Return the temperature for backward compatibility. + + Should not be set by integrations. + """ return self._attr_temperature @property - def temperature_unit(self) -> str: + def native_temperature(self) -> float | None: + """Return the temperature in native units.""" + if (temperature := self.temperature) is not None: + return temperature + + return self._attr_native_temperature + + @property + def native_temperature_unit(self) -> str | None: """Return the native unit of measurement for temperature.""" + if (temperature_unit := self.temperature_unit) is not None: + return temperature_unit + + return self._attr_native_temperature_unit + + @property + def temperature_unit(self) -> str | None: + """Return the temperature unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_temperature_unit + @final + @property + def _default_temperature_unit(self) -> str: + """Return the default unit of measurement for temperature. + + Should not be set by integrations. + """ + return self.hass.config.units.temperature_unit + + @final + @property + def _temperature_unit(self) -> str: + """Return the converted unit of measurement for temperature. + + Should not be set by integrations. + """ + if ( + weather_option_temperature_unit := self._weather_option_temperature_unit + ) is not None: + return weather_option_temperature_unit + + return self._default_temperature_unit + @property def pressure(self) -> float | None: - """Return the pressure in native units.""" + """Return the pressure for backward compatibility. + + Should not be set by integrations. + """ return self._attr_pressure @property - def pressure_unit(self) -> str | None: + def native_pressure(self) -> float | None: + """Return the pressure in native units.""" + if (pressure := self.pressure) is not None: + return pressure + + return self._attr_native_pressure + + @property + def native_pressure_unit(self) -> str | None: """Return the native unit of measurement for pressure.""" + if (pressure_unit := self.pressure_unit) is not None: + return pressure_unit + + return self._attr_native_pressure_unit + + @property + def pressure_unit(self) -> str | None: + """Return the pressure unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_pressure_unit + @final + @property + def _default_pressure_unit(self) -> str: + """Return the default unit of measurement for pressure. + + Should not be set by integrations. + """ + return PRESSURE_HPA if self.hass.config.units.is_metric else PRESSURE_INHG + + @final + @property + def _pressure_unit(self) -> str: + """Return the converted unit of measurement for pressure. + + Should not be set by integrations. + """ + if ( + weather_option_pressure_unit := self._weather_option_pressure_unit + ) is not None: + return weather_option_pressure_unit + + return self._default_pressure_unit + @property def humidity(self) -> float | None: """Return the humidity in native units.""" @@ -153,14 +438,63 @@ class WeatherEntity(Entity): @property def wind_speed(self) -> float | None: - """Return the wind speed in native units.""" + """Return the wind_speed for backward compatibility. + + Should not be set by integrations. + """ return self._attr_wind_speed @property - def wind_speed_unit(self) -> str | None: + def native_wind_speed(self) -> float | None: + """Return the wind speed in native units.""" + if (wind_speed := self.wind_speed) is not None: + return wind_speed + + return self._attr_native_wind_speed + + @property + def native_wind_speed_unit(self) -> str | None: """Return the native unit of measurement for wind speed.""" + if (wind_speed_unit := self.wind_speed_unit) is not None: + return wind_speed_unit + + return self._attr_native_wind_speed_unit + + @property + def wind_speed_unit(self) -> str | None: + """Return the wind_speed unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_wind_speed_unit + @final + @property + def _default_wind_speed_unit(self) -> str: + """Return the default unit of measurement for wind speed. + + Should not be set by integrations. + """ + return ( + SPEED_KILOMETERS_PER_HOUR + if self.hass.config.units.is_metric + else SPEED_MILES_PER_HOUR + ) + + @final + @property + def _wind_speed_unit(self) -> str: + """Return the converted unit of measurement for wind speed. + + Should not be set by integrations. + """ + if ( + weather_option_wind_speed_unit := self._weather_option_wind_speed_unit + ) is not None: + return weather_option_wind_speed_unit + + return self._default_wind_speed_unit + @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" @@ -173,24 +507,103 @@ class WeatherEntity(Entity): @property def visibility(self) -> float | None: - """Return the visibility in native units.""" + """Return the visibility for backward compatibility. + + Should not be set by integrations. + """ return self._attr_visibility @property - def visibility_unit(self) -> str | None: + def native_visibility(self) -> float | None: + """Return the visibility in native units.""" + if (visibility := self.visibility) is not None: + return visibility + + return self._attr_native_visibility + + @property + def native_visibility_unit(self) -> str | None: """Return the native unit of measurement for visibility.""" + if (visibility_unit := self.visibility_unit) is not None: + return visibility_unit + + return self._attr_native_visibility_unit + + @property + def visibility_unit(self) -> str | None: + """Return the visibility unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_visibility_unit + @final + @property + def _default_visibility_unit(self) -> str: + """Return the default unit of measurement for visibility. + + Should not be set by integrations. + """ + return self.hass.config.units.length_unit + + @final + @property + def _visibility_unit(self) -> str: + """Return the converted unit of measurement for visibility. + + Should not be set by integrations. + """ + if ( + weather_option_visibility_unit := self._weather_option_visibility_unit + ) is not None: + return weather_option_visibility_unit + + return self._default_visibility_unit + @property def forecast(self) -> list[Forecast] | None: """Return the forecast in native units.""" return self._attr_forecast @property - def precipitation_unit(self) -> str | None: + def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" + if (precipitation_unit := self.precipitation_unit) is not None: + return precipitation_unit + + return self._attr_native_precipitation_unit + + @property + def precipitation_unit(self) -> str | None: + """Return the precipitation unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_precipitation_unit + @final + @property + def _default_precipitation_unit(self) -> str: + """Return the default unit of measurement for precipitation. + + Should not be set by integrations. + """ + return self.hass.config.units.accumulated_precipitation_unit + + @final + @property + def _precipitation_unit(self) -> str: + """Return the converted unit of measurement for precipitation. + + Should not be set by integrations. + """ + if ( + weather_option_precipitation_unit := self._weather_option_precipitation_unit + ) is not None: + return weather_option_precipitation_unit + + return self._default_precipitation_unit + @property def precision(self) -> float: """Return the precision of the temperature value, after unit conversion.""" @@ -198,7 +611,7 @@ class WeatherEntity(Entity): return self._attr_precision return ( PRECISION_TENTHS - if self.hass.config.units.temperature_unit == TEMP_CELSIUS + if self._temperature_unit == TEMP_CELSIUS else PRECISION_WHOLE ) @@ -207,13 +620,24 @@ class WeatherEntity(Entity): def state_attributes(self): """Return the state attributes, converted from native units to user-configured units.""" data = {} - if self.temperature is not None: - data[ATTR_WEATHER_TEMPERATURE] = show_temp( - self.hass, - self.temperature, - self.temperature_unit, - self.precision, - ) + + precision = self.precision + + if (temperature := self.native_temperature) is not None: + from_unit = self.native_temperature_unit or self._default_temperature_unit + to_unit = self._temperature_unit + try: + temperature_f = float(temperature) + value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + temperature_f, from_unit, to_unit + ) + data[ATTR_WEATHER_TEMPERATURE] = round_temperature( + value_temp, precision + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_TEMPERATURE] = temperature + + data[ATTR_WEATHER_TEMPERATURE_UNIT] = self._temperature_unit if (humidity := self.humidity) is not None: data[ATTR_WEATHER_HUMIDITY] = round(humidity) @@ -221,77 +645,167 @@ class WeatherEntity(Entity): if (ozone := self.ozone) is not None: data[ATTR_WEATHER_OZONE] = ozone - if (pressure := self.pressure) is not None: - if (unit := self.pressure_unit) is not None: - pressure = round( - self.hass.config.units.pressure(pressure, unit), ROUNDING_PRECISION + if (pressure := self.native_pressure) is not None: + from_unit = self.native_pressure_unit or self._default_pressure_unit + to_unit = self._pressure_unit + try: + pressure_f = float(pressure) + value_pressure = UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( + pressure_f, from_unit, to_unit ) - data[ATTR_WEATHER_PRESSURE] = pressure + data[ATTR_WEATHER_PRESSURE] = round(value_pressure, ROUNDING_PRECISION) + except (TypeError, ValueError): + data[ATTR_WEATHER_PRESSURE] = pressure + + data[ATTR_WEATHER_PRESSURE_UNIT] = self._pressure_unit if (wind_bearing := self.wind_bearing) is not None: data[ATTR_WEATHER_WIND_BEARING] = wind_bearing - if (wind_speed := self.wind_speed) is not None: - if (unit := self.wind_speed_unit) is not None: - wind_speed = round( - self.hass.config.units.wind_speed(wind_speed, unit), - ROUNDING_PRECISION, + if (wind_speed := self.native_wind_speed) is not None: + from_unit = self.native_wind_speed_unit or self._default_wind_speed_unit + to_unit = self._wind_speed_unit + try: + wind_speed_f = float(wind_speed) + value_wind_speed = UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + wind_speed_f, from_unit, to_unit ) - data[ATTR_WEATHER_WIND_SPEED] = wind_speed + data[ATTR_WEATHER_WIND_SPEED] = round( + value_wind_speed, ROUNDING_PRECISION + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_WIND_SPEED] = wind_speed - if (visibility := self.visibility) is not None: - if (unit := self.visibility_unit) is not None: - visibility = round( - self.hass.config.units.length(visibility, unit), ROUNDING_PRECISION + data[ATTR_WEATHER_WIND_SPEED_UNIT] = self._wind_speed_unit + + if (visibility := self.native_visibility) is not None: + from_unit = self.native_visibility_unit or self._default_visibility_unit + to_unit = self._visibility_unit + try: + visibility_f = float(visibility) + value_visibility = UNIT_CONVERSIONS[ATTR_WEATHER_VISIBILITY_UNIT]( + visibility_f, from_unit, to_unit ) - data[ATTR_WEATHER_VISIBILITY] = visibility + data[ATTR_WEATHER_VISIBILITY] = round( + value_visibility, ROUNDING_PRECISION + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_VISIBILITY] = visibility + + data[ATTR_WEATHER_VISIBILITY_UNIT] = self._visibility_unit + data[ATTR_WEATHER_PRECIPITATION_UNIT] = self._precipitation_unit if self.forecast is not None: forecast = [] for forecast_entry in self.forecast: forecast_entry = dict(forecast_entry) - forecast_entry[ATTR_FORECAST_TEMP] = show_temp( - self.hass, - forecast_entry[ATTR_FORECAST_TEMP], - self.temperature_unit, - self.precision, + + temperature = forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP, forecast_entry.get(ATTR_FORECAST_TEMP) ) - if ATTR_FORECAST_TEMP_LOW in forecast_entry: - forecast_entry[ATTR_FORECAST_TEMP_LOW] = show_temp( - self.hass, - forecast_entry[ATTR_FORECAST_TEMP_LOW], - self.temperature_unit, - self.precision, + + from_temp_unit = ( + self.native_temperature_unit or self._default_temperature_unit + ) + to_temp_unit = self._temperature_unit + + if temperature is None: + forecast_entry[ATTR_FORECAST_TEMP] = None + else: + with suppress(TypeError, ValueError): + temperature_f = float(temperature) + value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + temperature_f, + from_temp_unit, + to_temp_unit, + ) + forecast_entry[ATTR_FORECAST_TEMP] = round_temperature( + value_temp, precision + ) + + if ( + forecast_temp_low := forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP_LOW, + forecast_entry.get(ATTR_FORECAST_TEMP_LOW), ) - if ( - native_pressure := forecast_entry.get(ATTR_FORECAST_PRESSURE) ) is not None: - if (unit := self.pressure_unit) is not None: - pressure = round( - self.hass.config.units.pressure(native_pressure, unit), - ROUNDING_PRECISION, + with suppress(TypeError, ValueError): + forecast_temp_low_f = float(forecast_temp_low) + value_temp_low = UNIT_CONVERSIONS[ + ATTR_WEATHER_TEMPERATURE_UNIT + ]( + forecast_temp_low_f, + from_temp_unit, + to_temp_unit, ) - forecast_entry[ATTR_FORECAST_PRESSURE] = pressure - if ( - native_wind_speed := forecast_entry.get(ATTR_FORECAST_WIND_SPEED) - ) is not None: - if (unit := self.wind_speed_unit) is not None: - wind_speed = round( - self.hass.config.units.wind_speed(native_wind_speed, unit), - ROUNDING_PRECISION, + + forecast_entry[ATTR_FORECAST_TEMP_LOW] = round_temperature( + value_temp_low, precision ) - forecast_entry[ATTR_FORECAST_WIND_SPEED] = wind_speed + if ( - native_precip := forecast_entry.get(ATTR_FORECAST_PRECIPITATION) + forecast_pressure := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRESSURE, + forecast_entry.get(ATTR_FORECAST_PRESSURE), + ) ) is not None: - if (unit := self.precipitation_unit) is not None: - precipitation = round( - self.hass.config.units.accumulated_precipitation( - native_precip, unit + from_pressure_unit = ( + self.native_pressure_unit or self._default_pressure_unit + ) + to_pressure_unit = self._pressure_unit + with suppress(TypeError, ValueError): + forecast_pressure_f = float(forecast_pressure) + forecast_entry[ATTR_FORECAST_PRESSURE] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( + forecast_pressure_f, + from_pressure_unit, + to_pressure_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_wind_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_SPEED, + forecast_entry.get(ATTR_FORECAST_WIND_SPEED), + ) + ) is not None: + from_wind_speed_unit = ( + self.native_wind_speed_unit or self._default_wind_speed_unit + ) + to_wind_speed_unit = self._wind_speed_unit + with suppress(TypeError, ValueError): + forecast_wind_speed_f = float(forecast_wind_speed) + forecast_entry[ATTR_FORECAST_WIND_SPEED] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + forecast_wind_speed_f, + from_wind_speed_unit, + to_wind_speed_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_precipitation := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRECIPITATION, + forecast_entry.get(ATTR_FORECAST_PRECIPITATION), + ) + ) is not None: + from_precipitation_unit = ( + self.native_precipitation_unit + or self._default_precipitation_unit + ) + to_precipitation_unit = self._precipitation_unit + with suppress(TypeError, ValueError): + forecast_precipitation_f = float(forecast_precipitation) + forecast_entry[ATTR_FORECAST_PRECIPITATION] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRECIPITATION_UNIT]( + forecast_precipitation_f, + from_precipitation_unit, + to_precipitation_unit, ), ROUNDING_PRECISION, ) - forecast_entry[ATTR_FORECAST_PRECIPITATION] = precipitation forecast.append(forecast_entry) @@ -309,3 +823,44 @@ class WeatherEntity(Entity): def condition(self) -> str | None: """Return the current condition.""" return self._attr_condition + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + assert self.registry_entry + self._weather_option_temperature_unit = None + self._weather_option_pressure_unit = None + self._weather_option_precipitation_unit = None + self._weather_option_wind_speed_unit = None + self._weather_option_visibility_unit = None + if weather_options := self.registry_entry.options.get(DOMAIN): + if ( + custom_unit_temperature := weather_options.get( + ATTR_WEATHER_TEMPERATURE_UNIT + ) + ) and custom_unit_temperature in VALID_UNITS[ATTR_WEATHER_TEMPERATURE_UNIT]: + self._weather_option_temperature_unit = custom_unit_temperature + if ( + custom_unit_pressure := weather_options.get(ATTR_WEATHER_PRESSURE_UNIT) + ) and custom_unit_pressure in VALID_UNITS[ATTR_WEATHER_PRESSURE_UNIT]: + self._weather_option_pressure_unit = custom_unit_pressure + if ( + custom_unit_precipitation := weather_options.get( + ATTR_WEATHER_PRECIPITATION_UNIT + ) + ) and custom_unit_precipitation in VALID_UNITS[ + ATTR_WEATHER_PRECIPITATION_UNIT + ]: + self._weather_option_precipitation_unit = custom_unit_precipitation + if ( + custom_unit_wind_speed := weather_options.get( + ATTR_WEATHER_WIND_SPEED_UNIT + ) + ) and custom_unit_wind_speed in VALID_UNITS[ATTR_WEATHER_WIND_SPEED_UNIT]: + self._weather_option_wind_speed_unit = custom_unit_wind_speed + if ( + custom_unit_visibility := weather_options.get( + ATTR_WEATHER_VISIBILITY_UNIT + ) + ) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]: + self._weather_option_visibility_unit = custom_unit_visibility diff --git a/homeassistant/components/weather/manifest.json b/homeassistant/components/weather/manifest.json index c77e8408c83..cbf04af989d 100644 --- a/homeassistant/components/weather/manifest.json +++ b/homeassistant/components/weather/manifest.json @@ -2,6 +2,6 @@ "domain": "weather", "name": "Weather", "documentation": "https://www.home-assistant.io/integrations/weather", - "codeowners": ["@fabaff"], + "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } diff --git a/homeassistant/components/webostv/translations/es.json b/homeassistant/components/webostv/translations/es.json index 712de905ddc..db23caa048b 100644 --- a/homeassistant/components/webostv/translations/es.json +++ b/homeassistant/components/webostv/translations/es.json @@ -16,6 +16,7 @@ }, "user": { "data": { + "host": "Anfitri\u00f3n", "name": "Nombre" }, "description": "Encienda la televisi\u00f3n, rellene los siguientes campos y haga clic en enviar", diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 61bcb8badf0..bea08722eb0 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -29,7 +29,7 @@ from homeassistant.helpers.event import ( TrackTemplateResult, async_track_template_result, ) -from homeassistant.helpers.json import ExtendedJSONEncoder +from homeassistant.helpers.json import JSON_DUMP, ExtendedJSONEncoder from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_get_loaded_integrations @@ -241,13 +241,13 @@ def handle_get_states( # to succeed for the UI to show. response = messages.result_message(msg["id"], states) try: - connection.send_message(const.JSON_DUMP(response)) + connection.send_message(JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=const.JSON_DUMP) + find_paths_unserializable_data(response, dump=JSON_DUMP) ), ) del response @@ -256,13 +256,13 @@ def handle_get_states( serialized = [] for state in states: try: - serialized.append(const.JSON_DUMP(state)) + serialized.append(JSON_DUMP(state)) except (ValueError, TypeError): # Error is already logged above pass # We now have partially serialized states. Craft some JSON. - response2 = const.JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) + response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized)) connection.send_message(response2) @@ -315,13 +315,13 @@ def handle_subscribe_entities( # to succeed for the UI to show. response = messages.event_message(msg["id"], data) try: - connection.send_message(const.JSON_DUMP(response)) + connection.send_message(JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=const.JSON_DUMP) + find_paths_unserializable_data(response, dump=JSON_DUMP) ), ) del response @@ -330,14 +330,14 @@ def handle_subscribe_entities( cannot_serialize: list[str] = [] for entity_id, state_dict in add_entities.items(): try: - const.JSON_DUMP(state_dict) + JSON_DUMP(state_dict) except (ValueError, TypeError): cannot_serialize.append(entity_id) for entity_id in cannot_serialize: del add_entities[entity_id] - connection.send_message(const.JSON_DUMP(messages.event_message(msg["id"], data))) + connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data))) @decorators.websocket_command({vol.Required("type"): "get_services"}) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 0280863f83e..26c4c6f8321 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.auth.models import RefreshToken, User from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers.json import JSON_DUMP from . import const, messages @@ -56,7 +57,7 @@ class ActiveConnection: async def send_big_result(self, msg_id: int, result: Any) -> None: """Send a result message that would be expensive to JSON serialize.""" content = await self.hass.async_add_executor_job( - const.JSON_DUMP, messages.result_message(msg_id, result) + JSON_DUMP, messages.result_message(msg_id, result) ) self.send_message(content) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 107cf6d0270..60a00126092 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -4,12 +4,9 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from concurrent import futures -from functools import partial -import json from typing import TYPE_CHECKING, Any, Final from homeassistant.core import HomeAssistant -from homeassistant.helpers.json import JSONEncoder if TYPE_CHECKING: from .connection import ActiveConnection # noqa: F401 @@ -53,10 +50,6 @@ SIGNAL_WEBSOCKET_DISCONNECTED: Final = "websocket_disconnected" # Data used to store the current connection list DATA_CONNECTIONS: Final = f"{DOMAIN}.connections" -JSON_DUMP: Final = partial( - json.dumps, cls=JSONEncoder, allow_nan=False, separators=(",", ":") -) - COMPRESSED_STATE_STATE = "s" COMPRESSED_STATE_ATTRIBUTES = "a" COMPRESSED_STATE_CONTEXT = "c" diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index f546ba5eec6..c3e5f6bb5f5 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.json import JSON_DUMP from homeassistant.util.json import ( find_paths_unserializable_data, format_unserializable_data, @@ -193,15 +194,15 @@ def compressed_state_dict_add(state: State) -> dict[str, Any]: def message_to_json(message: dict[str, Any]) -> str: """Serialize a websocket message to json.""" try: - return const.JSON_DUMP(message) + return JSON_DUMP(message) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(message, dump=const.JSON_DUMP) + find_paths_unserializable_data(message, dump=JSON_DUMP) ), ) - return const.JSON_DUMP( + return JSON_DUMP( error_message( message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index d24827bee96..81065cf8108 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -136,15 +136,18 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - self.set_percentage(percentage) + self._set_percentage(percentage) def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" with self._wemo_call_wrapper("turn off"): self.wemo.set_state(FanMode.Off) - def set_percentage(self, percentage: int | None) -> None: + def set_percentage(self, percentage: int) -> None: """Set the fan_mode of the Humidifier.""" + self._set_percentage(percentage) + + def _set_percentage(self, percentage: int | None) -> None: if percentage is None: named_speed = self._last_fan_on_mode elif percentage == 0: diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index b324ba060ea..5486a192787 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -14,5 +14,8 @@ }, "codeowners": ["@esev"], "iot_class": "local_push", - "loggers": ["pywemo"] + "loggers": ["pywemo"], + "supported_brands": { + "digital_loggers": "Digital Loggers" + } } diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 8f5e6864059..826df24a108 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -9,6 +9,8 @@ from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONFIGURATION_URL, + ATTR_IDENTIFIERS, CONF_DEVICE_ID, CONF_NAME, CONF_PARAMS, @@ -42,7 +44,7 @@ class DeviceCoordinator(DataUpdateCoordinator): self.hass = hass self.wemo = wemo self.device_id = device_id - self.device_info = _device_info(wemo) + self.device_info = _create_device_info(wemo) self.supports_long_press = wemo.supports_long_press() self.update_lock = asyncio.Lock() @@ -123,11 +125,14 @@ class DeviceCoordinator(DataUpdateCoordinator): except ActionException as err: raise UpdateFailed("WeMo update failed") from err - @callback - def async_update_listeners(self) -> None: - """Update all listeners.""" - for update_callback in self._listeners: - update_callback() + +def _create_device_info(wemo: WeMoDevice) -> DeviceInfo: + """Create device information. Modify if special device.""" + _dev_info = _device_info(wemo) + if wemo.model_name == "DLI emulated Belkin Socket": + _dev_info[ATTR_CONFIGURATION_URL] = f"http://{wemo.host}" + _dev_info[ATTR_IDENTIFIERS] = {(DOMAIN, wemo.serialnumber[:-1])} + return _dev_info def _device_info(wemo: WeMoDevice) -> DeviceInfo: @@ -150,7 +155,7 @@ async def async_register_device( device_registry = async_get_device_registry(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, **_device_info(wemo) + config_entry_id=config_entry.entry_id, **_create_device_info(wemo) ) device = DeviceCoordinator(hass, wemo, entry.id) diff --git a/homeassistant/components/whirlpool/translations/sv.json b/homeassistant/components/whirlpool/translations/sv.json new file mode 100644 index 00000000000..f7461922566 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index 8cbb0f6f502..00a2821c8c4 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -2,7 +2,7 @@ "domain": "whois", "name": "Whois", "documentation": "https://www.home-assistant.io/integrations/whois", - "requirements": ["whois==0.9.13"], + "requirements": ["whois==0.9.16"], "config_flow": true, "codeowners": ["@frenck"], "iot_class": "cloud_polling", diff --git a/homeassistant/components/whois/translations/es.json b/homeassistant/components/whois/translations/es.json index 0da233e02ad..e2803c251d6 100644 --- a/homeassistant/components/whois/translations/es.json +++ b/homeassistant/components/whois/translations/es.json @@ -4,7 +4,9 @@ "already_configured": "El servicio ya est\u00e1 configurado" }, "error": { + "unexpected_response": "Respuesta inesperada del servidor whois", "unknown_date_format": "Formato de fecha desconocido en la respuesta del servidor whois", + "unknown_tld": "El TLD dado es desconocido o no est\u00e1 disponible para esta integraci\u00f3n", "whois_command_failed": "El comando whois ha fallado: no se pudo obtener la informaci\u00f3n whois" }, "step": { diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 5087915181e..d6da03c2134 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -2,6 +2,8 @@ Used by UI to setup a wiffi integration. """ +from __future__ import annotations + import errno import voluptuous as vol @@ -21,7 +23,9 @@ class WiffiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Create Wiffi server setup option flow.""" return OptionsFlowHandler(config_entry) @@ -66,7 +70,7 @@ class WiffiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Wiffi server setup option flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 932ce1538bf..2cdcf20c1ea 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -1,5 +1,9 @@ """The WiLight integration.""" +from typing import Any + +from pywilight.wilight_device import Device as PyWiLightDevice + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -51,61 +55,43 @@ class WiLightDevice(Entity): Contains the common logic for WiLight entities. """ - def __init__(self, api_device, index, item_name): + _attr_should_poll = False + + def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: """Initialize the device.""" # WiLight specific attributes for every component type self._device_id = api_device.device_id - self._sw_version = api_device.swversion self._client = api_device.client - self._model = api_device.model - self._name = item_name self._index = index - self._unique_id = f"{self._device_id}_{self._index}" - self._status = {} + self._status: dict[str, Any] = {} - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return a name for this WiLight item.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID for this WiLight item.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - name=self._name, - identifiers={(DOMAIN, self._unique_id)}, - model=self._model, + self._attr_name = item_name + self._attr_unique_id = f"{self._device_id}_{index}" + self._attr_device_info = DeviceInfo( + name=item_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + model=api_device.model, manufacturer="WiLight", - sw_version=self._sw_version, + sw_version=api_device.swversion, via_device=(DOMAIN, self._device_id), ) @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return bool(self._client.is_connected) @callback - def handle_event_callback(self, states): + def handle_event_callback(self, states: dict[str, Any]) -> None: """Propagate changes through ha.""" self._status = states self.async_write_ha_state() - async def async_update(self): + async def async_update(self) -> None: """Synchronize state with api_device.""" await self._client.status(self._index) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" self._client.register_status_callback(self.handle_event_callback, self._index) await self._client.status(self._index) diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index ad94c224518..cd0a3cc21ac 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -1,4 +1,8 @@ """Support for WiLight Cover.""" +from __future__ import annotations + +from typing import Any + from pywilight.const import ( COVER_V1, ITEM_COVER, @@ -16,16 +20,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, WiLightDevice +from .parent_device import WiLightParent async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WiLight covers from a config entry.""" - parent = hass.data[DOMAIN][entry.entry_id] + parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] # Handle a discovered WiLight device. entities = [] + assert parent.api for item in parent.api.items: if item["type"] != ITEM_COVER: continue @@ -33,18 +39,17 @@ async def async_setup_entry( item_name = item["name"] if item["sub_type"] != COVER_V1: continue - entity = WiLightCover(parent.api, index, item_name) - entities.append(entity) + entities.append(WiLightCover(parent.api, index, item_name)) async_add_entities(entities) -def wilight_to_hass_position(value): +def wilight_to_hass_position(value: int) -> int: """Convert wilight position 1..255 to hass format 0..100.""" return min(100, round((value * 100) / 255)) -def hass_to_wilight_position(value): +def hass_to_wilight_position(value: int) -> int: """Convert hass position 0..100 to wilight 1..255 scale.""" return min(255, round((value * 255) / 100)) @@ -53,7 +58,7 @@ class WiLightCover(WiLightDevice, CoverEntity): """Representation of a WiLights cover.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -63,21 +68,21 @@ class WiLightCover(WiLightDevice, CoverEntity): return None @property - def is_opening(self): + def is_opening(self) -> bool | None: """Return if the cover is opening or not.""" if "motor_state" not in self._status: return None return self._status["motor_state"] == WL_OPENING @property - def is_closing(self): + def is_closing(self) -> bool | None: """Return if the cover is closing or not.""" if "motor_state" not in self._status: return None return self._status["motor_state"] == WL_CLOSING @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if "motor_state" not in self._status or "position_current" not in self._status: return None @@ -86,19 +91,19 @@ class WiLightCover(WiLightDevice, CoverEntity): and wilight_to_hass_position(self._status["position_current"]) == 0 ) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._client.cover_command(self._index, WL_OPEN) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._client.cover_command(self._index, WL_CLOSE) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = hass_to_wilight_position(kwargs[ATTR_POSITION]) await self._client.set_cover_position(self._index, position) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._client.cover_command(self._index, WL_STOP) diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index b96b4b89c61..c598e6db397 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -1,6 +1,8 @@ """Support for WiLight Fan.""" from __future__ import annotations +from typing import Any + from pywilight.const import ( FAN_V1, ITEM_FAN, @@ -11,6 +13,7 @@ from pywilight.const import ( WL_SPEED_LOW, WL_SPEED_MEDIUM, ) +from pywilight.wilight_device import Device as PyWiLightDevice from homeassistant.components.fan import DIRECTION_FORWARD, FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry @@ -22,6 +25,7 @@ from homeassistant.util.percentage import ( ) from . import DOMAIN, WiLightDevice +from .parent_device import WiLightParent ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH] @@ -30,10 +34,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WiLight lights from a config entry.""" - parent = hass.data[DOMAIN][entry.entry_id] + parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] # Handle a discovered WiLight device. entities = [] + assert parent.api for item in parent.api.items: if item["type"] != ITEM_FAN: continue @@ -41,8 +46,7 @@ async def async_setup_entry( item_name = item["name"] if item["sub_type"] != FAN_V1: continue - entity = WiLightFan(parent.api, index, item_name) - entities.append(entity) + entities.append(WiLightFan(parent.api, index, item_name)) async_add_entities(entities) @@ -50,21 +54,18 @@ async def async_setup_entry( class WiLightFan(WiLightDevice, FanEntity): """Representation of a WiLights fan.""" + _attr_icon = "mdi:fan" + _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION - def __init__(self, api_device, index, item_name): + def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: """Initialize the device.""" super().__init__(api_device, index, item_name) # Initialize the WiLights fan. self._direction = WL_DIRECTION_FORWARD @property - def icon(self): - """Return the icon of device based on its type.""" - return "mdi:fan" - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._status.get("direction", WL_DIRECTION_OFF) != WL_DIRECTION_OFF @@ -81,11 +82,6 @@ class WiLightFan(WiLightDevice, FanEntity): return None return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, wl_speed) - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return len(ORDERED_NAMED_FAN_SPEEDS) - @property def current_direction(self) -> str: """Return the current direction of the fan.""" @@ -98,9 +94,9 @@ class WiLightFan(WiLightDevice, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" if percentage is None: @@ -108,7 +104,7 @@ class WiLightFan(WiLightDevice, FanEntity): else: await self.async_set_percentage(percentage) - async def async_set_percentage(self, percentage: int): + async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan.""" if percentage == 0: await self._client.set_fan_direction(self._index, WL_DIRECTION_OFF) @@ -121,13 +117,13 @@ class WiLightFan(WiLightDevice, FanEntity): wl_speed = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) await self._client.set_fan_speed(self._index, wl_speed) - async def async_set_direction(self, direction: str): + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" wl_direction = WL_DIRECTION_REVERSE if direction == DIRECTION_FORWARD: wl_direction = WL_DIRECTION_FORWARD await self._client.set_fan_direction(self._index, wl_direction) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self._client.set_fan_direction(self._index, WL_DIRECTION_OFF) diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index 10ff79fe60d..ea9e19dcb30 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -1,5 +1,10 @@ """Support for WiLight lights.""" +from __future__ import annotations + +from typing import Any + from pywilight.const import ITEM_LIGHT, LIGHT_COLOR, LIGHT_DIMMER, LIGHT_ON_OFF +from pywilight.wilight_device import Device as PyWiLightDevice from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -12,25 +17,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, WiLightDevice +from .parent_device import WiLightParent -def entities_from_discovered_wilight(hass, api_device): +def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> list[LightEntity]: """Parse configuration and add WiLight light entities.""" - entities = [] + entities: list[LightEntity] = [] for item in api_device.items: if item["type"] != ITEM_LIGHT: continue index = item["index"] item_name = item["name"] if item["sub_type"] == LIGHT_ON_OFF: - entity = WiLightLightOnOff(api_device, index, item_name) + entities.append(WiLightLightOnOff(api_device, index, item_name)) elif item["sub_type"] == LIGHT_DIMMER: - entity = WiLightLightDimmer(api_device, index, item_name) + entities.append(WiLightLightDimmer(api_device, index, item_name)) elif item["sub_type"] == LIGHT_COLOR: - entity = WiLightLightColor(api_device, index, item_name) - else: - continue - entities.append(entity) + entities.append(WiLightLightColor(api_device, index, item_name)) return entities @@ -39,10 +42,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WiLight lights from a config entry.""" - parent = hass.data[DOMAIN][entry.entry_id] + parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] # Handle a discovered WiLight device. - entities = entities_from_discovered_wilight(hass, parent.api) + assert parent.api + entities = entities_from_discovered_wilight(parent.api) async_add_entities(entities) @@ -53,15 +57,15 @@ class WiLightLightOnOff(WiLightDevice, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._status.get("on") - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._client.turn_off(self._index) @@ -73,16 +77,16 @@ class WiLightLightDimmer(WiLightDevice, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(self._status.get("brightness", 0)) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._status.get("on") - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on,set brightness if needed.""" # Dimmer switches use a range of [0, 255] to control # brightness. Level 255 might mean to set it to previous value @@ -92,27 +96,27 @@ class WiLightLightDimmer(WiLightDevice, LightEntity): else: await self._client.turn_on(self._index) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._client.turn_off(self._index) -def wilight_to_hass_hue(value): +def wilight_to_hass_hue(value: int) -> float: """Convert wilight hue 1..255 to hass 0..360 scale.""" return min(360, round((value * 360) / 255, 3)) -def hass_to_wilight_hue(value): +def hass_to_wilight_hue(value: float) -> int: """Convert hass hue 0..360 to wilight 1..255 scale.""" return min(255, round((value * 255) / 360)) -def wilight_to_hass_saturation(value): +def wilight_to_hass_saturation(value: int) -> float: """Convert wilight saturation 1..255 to hass 0..100 scale.""" return min(100, round((value * 100) / 255, 3)) -def hass_to_wilight_saturation(value): +def hass_to_wilight_saturation(value: float) -> int: """Convert hass saturation 0..100 to wilight 1..255 scale.""" return min(255, round((value * 255) / 100)) @@ -124,24 +128,24 @@ class WiLightLightColor(WiLightDevice, LightEntity): _attr_supported_color_modes = {ColorMode.HS} @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(self._status.get("brightness", 0)) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hue and saturation color value [float, float].""" - return [ + return ( wilight_to_hass_hue(int(self._status.get("hue", 0))), wilight_to_hass_saturation(int(self._status.get("saturation", 0))), - ] + ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._status.get("on") - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on,set brightness if needed.""" # Brightness use a range of [0, 255] to control # Hue use a range of [0, 360] to control @@ -161,6 +165,6 @@ class WiLightLightColor(WiLightDevice, LightEntity): else: await self._client.turn_on(self._index) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._client.turn_off(self._index) diff --git a/homeassistant/components/wilight/parent_device.py b/homeassistant/components/wilight/parent_device.py index faf71b74f72..17a33fef633 100644 --- a/homeassistant/components/wilight/parent_device.py +++ b/homeassistant/components/wilight/parent_device.py @@ -1,12 +1,16 @@ """The WiLight Device integration.""" +from __future__ import annotations + import asyncio import logging import pywilight +from pywilight.wilight_device import Device as PyWiLightDevice import requests +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) @@ -15,23 +19,23 @@ _LOGGER = logging.getLogger(__name__) class WiLightParent: """Manages a single WiLight Parent Device.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the system.""" - self._host = config_entry.data[CONF_HOST] + self._host: str = config_entry.data[CONF_HOST] self._hass = hass - self._api = None + self._api: PyWiLightDevice | None = None @property - def host(self): + def host(self) -> str: """Return the host of this parent.""" return self._host @property - def api(self): + def api(self) -> PyWiLightDevice | None: """Return the api of this parent.""" return self._api - async def async_setup(self): + async def async_setup(self) -> bool: """Set up a WiLight Parent Device based on host parameter.""" host = self._host hass = self._hass @@ -42,7 +46,7 @@ class WiLightParent: return False @callback - def disconnected(): + def disconnected() -> None: # Schedule reconnect after connection has been lost. _LOGGER.warning("WiLight %s disconnected", api_device.device_id) async_dispatcher_send( @@ -50,14 +54,14 @@ class WiLightParent: ) @callback - def reconnected(): + def reconnected() -> None: # Schedule reconnect after connection has been lost. _LOGGER.warning("WiLight %s reconnect", api_device.device_id) async_dispatcher_send( hass, f"wilight_device_available_{api_device.device_id}", True ) - async def connect(api_device): + async def connect(api_device: PyWiLightDevice) -> None: # Set up connection and hook it into HA for reconnect/shutdown. _LOGGER.debug("Initiating connection to %s", api_device.device_id) @@ -81,7 +85,7 @@ class WiLightParent: return True - async def async_reset(self): + async def async_reset(self) -> None: """Reset api.""" # If the initialization was not wrong. @@ -89,15 +93,13 @@ class WiLightParent: self._api.client.stop() -def create_api_device(host): +def create_api_device(host: str) -> PyWiLightDevice: """Create an API Device.""" try: - device = pywilight.device_from_host(host) + return pywilight.device_from_host(host) except ( requests.exceptions.ConnectionError, requests.exceptions.Timeout, ) as err: _LOGGER.error("Unable to access WiLight at %s (%s)", host, err) return None - - return device diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index b447973af97..a4ac6597248 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -1,12 +1,15 @@ """Config flow for Withings.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any import voluptuous as vol from withings_api.common import AuthScope from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util import slugify @@ -29,7 +32,7 @@ class WithingsFlowHandler( return logging.getLogger(__name__) @property - def extra_authorize_data(self) -> dict: + def extra_authorize_data(self) -> dict[str, str]: """Extra data that needs to be appended to the authorize url.""" return { "scope": ",".join( @@ -42,12 +45,12 @@ class WithingsFlowHandler( ) } - async def async_oauth_create_entry(self, data: dict) -> dict: + 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) -> dict: + async def async_step_profile(self, data: dict[str, Any]) -> FlowResult: """Prompt the user to select a user profile.""" errors = {} reauth_profile = ( @@ -77,7 +80,7 @@ class WithingsFlowHandler( errors=errors, ) - async def async_step_reauth(self, data: dict = None) -> dict: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Prompt user to re-authenticate.""" if data is not None: return await self.async_step_user() @@ -91,7 +94,7 @@ class WithingsFlowHandler( description_placeholders=placeholders, ) - async def async_step_finish(self, data: dict) -> dict: + async def async_step_finish(self, data: dict[str, Any]) -> FlowResult: """Finish the flow.""" self._current_data = {} diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 104ecb6f0c5..b47db32d90f 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -1,4 +1,6 @@ """WiZ Platform integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -80,10 +82,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Found bulb {bulb.mac} at {ip_address}, expected {entry.unique_id}" ) - async def _async_update() -> None: + async def _async_update() -> float | None: """Update the WiZ device.""" try: await bulb.updateState() + if bulb.power_monitoring is not False: + power: float | None = await bulb.get_power() + return power + return None except WIZ_EXCEPTIONS as ex: raise UpdateFailed(f"Failed to update device at {ip_address}: {ex}") from ex @@ -117,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _async_push_update(state: PilotParser) -> None: """Receive a push update.""" _LOGGER.debug("%s: Got push update: %s", bulb.mac, state.pilotResult) - coordinator.async_set_updated_data(None) + coordinator.async_set_updated_data(coordinator.data) if state.get_source() == PIR_SOURCE: async_dispatcher_send(hass, SIGNAL_WIZ_PIR.format(bulb.mac)) diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 04a0884059f..b1bce3eda0d 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -9,8 +9,8 @@ from pywizlight.discovery import DiscoveredBulb from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components import dhcp +from homeassistant.components import dhcp, onboarding +from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.util.network import is_ip_address @@ -24,16 +24,17 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class WizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for WiZ.""" VERSION = 1 + _discovered_device: DiscoveredBulb + _name: str + def __init__(self) -> None: """Initialize the config flow.""" - self._discovered_device: DiscoveredBulb | None = None self._discovered_devices: dict[str, DiscoveredBulb] = {} - self._name: str | None = None async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" @@ -54,7 +55,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_handle_discovery(self) -> FlowResult: """Handle any discovery.""" device = self._discovered_device - assert device is not None _LOGGER.debug("Discovered device: %s", device) ip_address = device.ip_address mac = device.mac_address @@ -66,7 +66,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_connect_discovered_or_abort(self) -> None: """Connect to the device and verify its responding.""" device = self._discovered_device - assert device is not None bulb = wizlight(device.ip_address) try: bulbtype = await bulb.get_bulbtype() @@ -84,10 +83,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm discovery.""" - assert self._discovered_device is not None - assert self._name is not None ip_address = self._discovered_device.ip_address - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): # Make sure the device is still there and # update the name if the firmware has auto # updated since discovery diff --git a/homeassistant/components/wiz/entity.py b/homeassistant/components/wiz/entity.py index 9b22d35de7d..c78f3e3b37b 100644 --- a/homeassistant/components/wiz/entity.py +++ b/homeassistant/components/wiz/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any +from typing import Any, Optional from pywizlight.bulblibrary import BulbType @@ -10,12 +10,15 @@ from homeassistant.const import ATTR_HW_VERSION, ATTR_MODEL from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .models import WizData -class WizEntity(CoordinatorEntity, Entity): +class WizEntity(CoordinatorEntity[DataUpdateCoordinator[Optional[float]]], Entity): """Representation of WiZ entity.""" def __init__(self, wiz_data: WizData, name: str) -> None: diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index a3537d3cbd9..c2eb6fe1b53 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -13,7 +13,7 @@ "dependencies": ["network"], "quality_scale": "platinum", "documentation": "https://www.home-assistant.io/integrations/wiz", - "requirements": ["pywizlight==0.5.13"], + "requirements": ["pywizlight==0.5.14"], "iot_class": "local_push", "codeowners": ["@sbidy"] } diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index f7d827534b3..d2f68fcf7c3 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -48,9 +48,9 @@ async def _async_set_ratio(device: wizlight, ratio: int) -> None: NUMBERS: tuple[WizNumberEntityDescription, ...] = ( WizNumberEntityDescription( key="effect_speed", - min_value=10, - max_value=200, - step=1, + native_min_value=10, + native_max_value=200, + native_step=1, icon="mdi:speedometer", name="Effect Speed", value_fn=lambda device: cast(Optional[int], device.state.get_speed()), @@ -59,9 +59,9 @@ NUMBERS: tuple[WizNumberEntityDescription, ...] = ( ), WizNumberEntityDescription( key="dual_head_ratio", - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, icon="mdi:floor-lamp-dual", name="Dual Head Ratio", value_fn=lambda device: cast(Optional[int], device.state.get_ratio()), @@ -113,9 +113,9 @@ class WizSpeedNumber(WizEntity, NumberEntity): def _async_update_attrs(self) -> None: """Handle updating _attr values.""" if (value := self.entity_description.value_fn(self._device)) is not None: - self._attr_value = float(value) + self._attr_native_value = float(value) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the speed value.""" await self.entity_description.set_value_fn(self._device, int(value)) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py index d16130883d5..11f3933fd16 100644 --- a/homeassistant/components/wiz/sensor.py +++ b/homeassistant/components/wiz/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.const import POWER_WATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,6 +30,17 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ) +POWER_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="power", + name="Current Power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -37,9 +48,17 @@ async def async_setup_entry( ) -> None: """Set up the wiz sensor.""" wiz_data: WizData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + entities = [ WizSensor(wiz_data, entry.title, description) for description in SENSORS - ) + ] + if wiz_data.coordinator.data is not None: + entities.extend( + [ + WizPowerSensor(wiz_data, entry.title, description) + for description in POWER_SENSORS + ] + ) + async_add_entities(entities) class WizSensor(WizEntity, SensorEntity): @@ -63,3 +82,16 @@ class WizSensor(WizEntity, SensorEntity): self._attr_native_value = self._device.state.pilotResult.get( self.entity_description.key ) + + +class WizPowerSensor(WizSensor): + """Defines a WiZ power sensor.""" + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + # Newer firmwares will have the power in their state + watts_push = self._device.state.get_power() + # Older firmwares will be polled and in the coordinator data + watts_poll = self.coordinator.data + self._attr_native_value = watts_poll if watts_push is None else watts_push diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 99630f5781c..1dda368a2b0 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol from wled import WLED, Device, WLEDConnectionError -from homeassistant.components import zeroconf +from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -97,7 +97,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initiated by zeroconf.""" - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self.async_create_entry( title=self.discovered_device.info.name, data={ diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index a4cbaade8ba..81017779fbb 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -54,11 +54,6 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): self.data is not None and len(self.data.state.segments) > 1 ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() - @callback def _use_websocket(self) -> None: """Use WebSocket for updates, instead of polling.""" @@ -81,7 +76,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): self.logger.info(err) except WLEDError as err: self.last_update_success = False - self.update_listeners() + self.async_update_listeners() self.logger.error(err) # Ensure we are disconnected diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index 66cd8b13b42..77e288bb34d 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -15,11 +15,11 @@ def wled_exception_handler(func): async def handler(self, *args, **kwargs): try: await func(self, *args, **kwargs) - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except WLEDConnectionError as error: self.coordinator.last_update_success = False - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() raise HomeAssistantError("Error communicating with WLED API") from error except WLEDError as error: diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index d551072e452..6c426cc44c5 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -41,17 +41,17 @@ NUMBERS = [ name="Speed", icon="mdi:speedometer", entity_category=EntityCategory.CONFIG, - step=1, - min_value=0, - max_value=255, + native_step=1, + native_min_value=0, + native_max_value=255, ), NumberEntityDescription( key=ATTR_INTENSITY, name="Intensity", entity_category=EntityCategory.CONFIG, - step=1, - min_value=0, - max_value=255, + native_step=1, + native_min_value=0, + native_max_value=255, ), ] @@ -93,7 +93,7 @@ class WLEDNumber(WLEDEntity, NumberEntity): return super().available @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the current WLED segment number value.""" return getattr( self.coordinator.data.state.segments[self._segment], @@ -101,7 +101,7 @@ class WLEDNumber(WLEDEntity, NumberEntity): ) @wled_exception_handler - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the WLED segment value.""" key = self.entity_description.key if key == ATTR_SPEED: diff --git a/homeassistant/components/wled/translations/select.sv.json b/homeassistant/components/wled/translations/select.sv.json new file mode 100644 index 00000000000..1c3bb4c1ff4 --- /dev/null +++ b/homeassistant/components/wled/translations/select.sv.json @@ -0,0 +1,7 @@ +{ + "state": { + "wled__live_override": { + "1": "P\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.bg.json b/homeassistant/components/wolflink/translations/sensor.bg.json index 4a402cfe75b..8d0335dcc31 100644 --- a/homeassistant/components/wolflink/translations/sensor.bg.json +++ b/homeassistant/components/wolflink/translations/sensor.bg.json @@ -3,6 +3,9 @@ "wolflink__state": { "1_x_warmwasser": "1 x DHW", "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u043d", + "dhw_prior": "DHWPrior", + "gasdruck": "\u041d\u0430\u043b\u044f\u0433\u0430\u043d\u0435 \u043d\u0430 \u0433\u0430\u0437\u0430", + "kalibration": "\u041a\u0430\u043b\u0438\u0431\u0440\u0438\u0440\u0430\u043d\u0435", "test": "\u0422\u0435\u0441\u0442", "tpw": "TPW", "urlaubsmodus": "\u0412\u0430\u043a\u0430\u043d\u0446\u0438\u043e\u043d\u0435\u043d \u0440\u0435\u0436\u0438\u043c", diff --git a/homeassistant/components/wolflink/translations/sensor.sv.json b/homeassistant/components/wolflink/translations/sensor.sv.json index 7b55b80227e..ddfd466dce2 100644 --- a/homeassistant/components/wolflink/translations/sensor.sv.json +++ b/homeassistant/components/wolflink/translations/sensor.sv.json @@ -3,6 +3,7 @@ "wolflink__state": { "aktiviert": "Aktiverad", "aus": "Inaktiverad", + "sparen": "Ekonomi", "test": "Test" } } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 6028e6e6fc2..186ae56bbdc 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.13"], + "requirements": ["holidays==0.14.2"], "codeowners": ["@fabaff"], "quality_scale": "internal", "iot_class": "local_polling", diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index b84872da036..a7deb74eb3e 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -1,4 +1,6 @@ """Config flow for WS66i 6-Zone Amplifier integration.""" +from __future__ import annotations + import logging from typing import Any @@ -114,7 +116,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> Ws66iOptionsFlowHandler: """Define the config flow to handle options.""" return Ws66iOptionsFlowHandler(config_entry) @@ -131,7 +135,7 @@ def _key_for_source(index, source, previous_sources): class Ws66iOptionsFlowHandler(config_entries.OptionsFlow): """Handle a WS66i options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry diff --git a/homeassistant/components/ws66i/translations/en.json b/homeassistant/components/ws66i/translations/en.json index fd4b170b378..30ef1e4205a 100644 --- a/homeassistant/components/ws66i/translations/en.json +++ b/homeassistant/components/ws66i/translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Device is already configured" + }, "error": { "cannot_connect": "Failed to connect", "unknown": "Unexpected error" diff --git a/homeassistant/components/ws66i/translations/pt-BR.json b/homeassistant/components/ws66i/translations/pt-BR.json index d440aab3aa4..68bc805d08c 100644 --- a/homeassistant/components/ws66i/translations/pt-BR.json +++ b/homeassistant/components/ws66i/translations/pt-BR.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "unknown": "Erro inesperado" }, "step": { diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 1857b6e83b2..0c9696a42ef 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -82,7 +82,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def play_ringtone_service(call: ServiceCall) -> None: """Service to play ringtone through Gateway.""" ring_id = call.data.get(ATTR_RINGTONE_ID) - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] kwargs = {"mid": ring_id} @@ -93,12 +93,12 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def stop_ringtone_service(call: ServiceCall) -> None: """Service to stop playing ringtone on Gateway.""" - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] gateway.write_to_hub(gateway.sid, mid=10000) def add_device_service(call: ServiceCall) -> None: """Service to add a new sub-device within the next 30 seconds.""" - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] gateway.write_to_hub(gateway.sid, join_permission="yes") persistent_notification.async_create( hass, @@ -110,7 +110,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def remove_device_service(call: ServiceCall) -> None: """Service to remove a sub-device from the gateway.""" device_id = call.data.get(ATTR_DEVICE_ID) - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] gateway.write_to_hub(gateway.sid, remove_device=device_id) gateway_only_schema = _add_gateway_to_schema(hass, vol.Schema({})) @@ -181,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_HOST], ) + assert entry.unique_id device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index ef773805849..04e3945e7a2 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -34,7 +34,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" - entities = [] + entities: list[XiaomiBinarySensor] = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for entity in gateway.devices["binary_sensor"]: model = entity["model"] diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index 422d9b21e0d..b6de7189d83 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -1,4 +1,6 @@ """Support for Xiaomi curtain.""" +from typing import Any + from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -44,28 +46,28 @@ class XiaomiGenericCover(XiaomiDevice, CoverEntity): super().__init__(device, name, xiaomi_hub, config_entry) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of the cover.""" return self._pos @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.current_cover_position <= 0 - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._write_to_hub(self._sid, **{self._data_key: "close"}) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._write_to_hub(self._sid, **{self._data_key: "open"}) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._write_to_hub(self._sid, **{self._data_key: "stop"}) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs.get(ATTR_POSITION) if self._data_key == DATA_KEY_PROTO_V2: diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index e21967a9f06..fea729b2b47 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -1,4 +1,6 @@ """Support for Xiaomi Aqara locks.""" +from __future__ import annotations + from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED @@ -44,18 +46,19 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): super().__init__(device, name, xiaomi_hub, config_entry) @property - def is_locked(self) -> bool: + def is_locked(self) -> bool | None: """Return true if lock is locked.""" if self._state is not None: return self._state == STATE_LOCKED + return None @property - def changed_by(self) -> int: + def changed_by(self) -> str: """Last change triggered by.""" return self._changed_by @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int]: """Return the state attributes.""" attributes = {ATTR_VERIFIED_WRONG_TIMES: self._verified_wrong_times} return attributes diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 9c295c3fe0a..5deed77d775 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,36 +31,43 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { key="temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), "humidity": SensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), "illumination": SensorEntityDescription( key="illumination", native_unit_of_measurement="lm", device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), "lux": SensorEntityDescription( key="lux", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), "pressure": SensorEntityDescription( key="pressure", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), "bed_activity": SensorEntityDescription( key="bed_activity", native_unit_of_measurement="μm", device_class=None, + state_class=SensorStateClass.MEASUREMENT, ), "load_power": SensorEntityDescription( key="load_power", native_unit_of_measurement=POWER_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), "final_tilt_angle": SensorEntityDescription( key="final_tilt_angle", @@ -69,6 +77,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "Battery": SensorEntityDescription( key="Battery", + state_class=SensorStateClass.MEASUREMENT, ), } @@ -79,7 +88,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" - entities = [] + entities: list[XiaomiSensor | XiaomiBatterySensor] = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for device in gateway.devices["sensor"]: if device["model"] == "sensor_ht": diff --git a/homeassistant/components/xiaomi_aqara/translations/he.json b/homeassistant/components/xiaomi_aqara/translations/he.json index 8d123517560..304d45f4cda 100644 --- a/homeassistant/components/xiaomi_aqara/translations/he.json +++ b/homeassistant/components/xiaomi_aqara/translations/he.json @@ -18,7 +18,7 @@ "data": { "select_ip": "\u05db\u05ea\u05d5\u05d1\u05ea IP" }, - "description": "\u05d9\u05e9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4 \u05e9\u05d5\u05d1 \u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8 \u05e9\u05e2\u05e8\u05d9\u05dd \u05e0\u05d5\u05e1\u05e4\u05d9\u05dd" + "description": "\u05d1\u05d7\u05d9\u05e8\u05ea \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e9\u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8" }, "settings": { "data": { diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 25c995b2b24..b5057a4a3dd 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Xiomi Gateway alarm control panels.""" +from __future__ import annotations + from functools import partial import logging @@ -49,6 +51,7 @@ async def async_setup_entry( class XiaomiGatewayAlarm(AlarmControlPanelEntity): """Representation of the XiaomiGatewayAlarm.""" + _attr_icon = "mdi:shield-home" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY def __init__( @@ -56,50 +59,13 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): ): """Initialize the entity.""" self._gateway = gateway_device - self._name = gateway_name - self._gateway_device_id = gateway_device_id - self._unique_id = f"{model}-{mac_address}" - self._icon = "mdi:shield-home" - self._available = None - self._state = None - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def device_id(self): - """Return the device id of the gateway.""" - return self._gateway_device_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the gateway.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway_device_id)}, + self._attr_name = gateway_name + self._attr_unique_id = f"{model}-{mac_address}" + self._attr_available = False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gateway_device_id)}, ) - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name - - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def state(self): - """Return the state of the device.""" - return self._state - async def _try_command(self, mask_error, func, *args, **kwargs): """Call a device command handling error messages.""" try: @@ -110,39 +76,39 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): except DeviceException as exc: _LOGGER.error(mask_error, exc) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Turn on.""" await self._try_command( "Turning the alarm on failed: %s", self._gateway.alarm.on ) - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Turn off.""" await self._try_command( "Turning the alarm off failed: %s", self._gateway.alarm.off ) - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the device.""" try: state = await self.hass.async_add_executor_job(self._gateway.alarm.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True + self._attr_available = True if state == XIAOMI_STATE_ARMED_VALUE: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif state == XIAOMI_STATE_DISARMED_VALUE: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif state == XIAOMI_STATE_ARMING_VALUE: - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING else: _LOGGER.warning( "New state (%s) doesn't match expected values: %s/%s/%s", @@ -151,6 +117,6 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): XIAOMI_STATE_DISARMED_VALUE, XIAOMI_STATE_ARMING_VALUE, ) - self._state = None + self._attr_state = None - _LOGGER.debug("State value: %s", self._state) + _LOGGER.debug("State value: %s", self._attr_state) diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 6a69289f7ef..0f5b59a262d 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from homeassistant.components.button import ( ButtonDeviceClass, @@ -111,7 +110,7 @@ class XiaomiGenericCoordinatedButton(XiaomiCoordinatedMiioEntity, ButtonEntity): super().__init__(name, device, entry, unique_id, coordinator) self.entity_description = description - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" method = getattr(self._device, self.entity_description.method_press) await self._try_command( diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index a78e01c7fae..4e2ba24bc05 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -1,6 +1,10 @@ """Config flow to configure Xiaomi Miio.""" +from __future__ import annotations + +from collections.abc import Mapping import logging from re import search +from typing import Any from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied @@ -8,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -61,7 +65,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" errors = {} if user_input is not None: @@ -105,39 +111,41 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self.host = None - self.mac = None + self.host: str | None = None + self.mac: str | None = None self.token = None self.model = None self.name = None self.cloud_username = None self.cloud_password = None self.cloud_country = None - self.cloud_devices = {} + self.cloud_devices: dict[str, dict[str, Any]] = {} @staticmethod @callback - def async_get_options_flow(config_entry) -> OptionsFlowHandler: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an authentication error or missing cloud credentials.""" - self.host = user_input[CONF_HOST] - self.token = user_input[CONF_TOKEN] - self.mac = user_input[CONF_MAC] - self.model = user_input.get(CONF_MODEL) + self.host = entry_data[CONF_HOST] + self.token = entry_data[CONF_TOKEN] + self.mac = entry_data[CONF_MAC] + self.model = entry_data.get(CONF_MODEL) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + 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 not None: return await self.async_step_cloud() return self.async_show_form(step_id="reauth_confirm") - async def async_step_import(self, conf: dict): + async def async_step_import(self, conf: dict[str, Any]) -> FlowResult: """Import a configuration from config.yaml.""" self.host = conf[CONF_HOST] self.token = conf[CONF_TOKEN] @@ -149,7 +157,9 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_connect() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" return await self.async_step_cloud() @@ -203,7 +213,7 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="not_xiaomi_miio") - def extract_cloud_info(self, cloud_device_info): + def extract_cloud_info(self, cloud_device_info: dict[str, Any]) -> None: """Extract the cloud info.""" if self.host is None: self.host = cloud_device_info["localip"] @@ -215,7 +225,9 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.name = cloud_device_info["name"] self.token = cloud_device_info["token"] - async def async_step_cloud(self, user_input=None): + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Configure a xiaomi miio device through the Miio Cloud.""" errors = {} if user_input is not None: @@ -283,9 +295,11 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors ) - async def async_step_select(self, user_input=None): + async def async_step_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle multiple cloud devices found.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: cloud_device = self.cloud_devices[user_input["select_device"]] self.extract_cloud_info(cloud_device) @@ -299,9 +313,11 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="select", data_schema=select_schema, errors=errors ) - async def async_step_manual(self, user_input=None): + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Configure a xiaomi miio device Manually.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: self.token = user_input[CONF_TOKEN] if user_input.get(CONF_HOST): @@ -316,9 +332,11 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="manual", data_schema=schema, errors=errors) - async def async_step_connect(self, user_input=None): + async def async_step_connect( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Connect to a xiaomi miio device.""" - errors = {} + errors: dict[str, str] = {} if self.host is None or self.token is None: return self.async_abort(reason="incomplete_info") diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 969093545f0..ac85955b347 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,8 +1,11 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" +from __future__ import annotations + from abc import abstractmethod import asyncio import logging import math +from typing import Any from miio.airfresh import OperationMode as AirfreshOperationMode from miio.airfresh_t2017 import OperationMode as AirfreshOperationModeT2017 @@ -291,30 +294,30 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Hold operation mode class.""" @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return self._preset_modes @property - def percentage(self): + def percentage(self) -> None: """Return the percentage based speed of the fan.""" return None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the device.""" return self._state_attrs @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._state async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn the device on.""" result = await self._try_command( @@ -331,7 +334,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" result = await self._try_command( "Turning the miio device off failed.", self._device.off @@ -352,12 +355,12 @@ class XiaomiGenericAirPurifier(XiaomiGenericDevice): self._speed_count = 100 @property - def speed_count(self): + def speed_count(self) -> int: """Return the number of speeds of the fan supported.""" return self._speed_count @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the active preset mode.""" if self._state: preset_mode = self.operation_mode_class(self._mode).name @@ -451,7 +454,7 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): return AirpurifierOperationMode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._state: mode = self.operation_mode_class(self._mode) @@ -528,7 +531,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): return AirpurifierMiotOperationMode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._fan_level is None: return None @@ -642,7 +645,7 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): return AirfreshOperationMode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._state: mode = AirfreshOperationMode(self._mode) @@ -733,7 +736,7 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): return AirfreshOperationModeT2017 @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._favorite_speed is None: return None @@ -826,17 +829,17 @@ class XiaomiGenericFan(XiaomiGenericDevice): self._percentage = None @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the active preset mode.""" return self._preset_mode @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return [mode.name for mode in self.operation_mode_class] @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed as a percentage.""" if self._state: return self._percentage @@ -844,7 +847,7 @@ class XiaomiGenericFan(XiaomiGenericDevice): return None @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" return self._oscillating @@ -890,12 +893,12 @@ class XiaomiFan(XiaomiGenericFan): """Hold operation mode class.""" @property - def preset_mode(self): + def preset_mode(self) -> str: """Get the active preset mode.""" return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] @@ -1030,7 +1033,7 @@ class XiaomiFanMiot(XiaomiGenericFan): return FanOperationMode @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the active preset mode.""" return self._preset_mode diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index e97c6e76503..28feb23d93e 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -19,9 +19,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + ColorMode, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -234,6 +232,9 @@ async def async_setup_entry( class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): """Representation of a Abstract Xiaomi Philips Light.""" + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + def __init__(self, name, device, entry, unique_id): """Initialize the light device.""" super().__init__(name, device, entry, unique_id) @@ -263,11 +264,6 @@ class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): """Return the brightness of this light between 0..255.""" return self._brightness - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS - async def _try_command(self, mask_error, func, *args, **kwargs): """Call a light command handling error messages.""" try: @@ -399,6 +395,9 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): """Representation of a Xiaomi Philips Bulb.""" + _attr_color_mode = ColorMode.COLOR_TEMP + _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + def __init__(self, name, device, entry, unique_id): """Initialize the light device.""" super().__init__(name, device, entry, unique_id) @@ -420,11 +419,6 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): """Return the warmest color_temp that this light supports.""" return 333 - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_COLOR_TEMP in kwargs: @@ -760,6 +754,8 @@ class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): """Representation of a Xiaomi Philips Zhirui Bedside Lamp.""" + _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} + def __init__(self, name, device, entry, unique_id): """Initialize the light device.""" super().__init__(name, device, entry, unique_id) @@ -792,9 +788,11 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): return self._hs_color @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP + def color_mode(self): + """Return the color mode of the light.""" + if self.hs_color: + return ColorMode.HS + return ColorMode.COLOR_TEMP async def async_turn_on(self, **kwargs): """Turn the light on.""" @@ -935,6 +933,9 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): class XiaomiGatewayLight(LightEntity): """Representation of a gateway device's light.""" + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + def __init__(self, gateway_device, gateway_name, gateway_device_id): """Initialize the XiaomiGatewayLight.""" self._gateway = gateway_device @@ -984,11 +985,6 @@ class XiaomiGatewayLight(LightEntity): """Return the hs color value.""" return self._hs - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR - def turn_on(self, **kwargs): """Turn the light on.""" if ATTR_HS_COLOR in kwargs: @@ -1036,6 +1032,9 @@ class XiaomiGatewayLight(LightEntity): class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): """Representation of Xiaomi Gateway Bulb.""" + _attr_color_mode = ColorMode.COLOR_TEMP + _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + @property def brightness(self): """Return the brightness of the light.""" @@ -1061,11 +1060,6 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): """Return max cct.""" return self._sub_device.status["cct_max"] - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" await self.hass.async_add_executor_job(self._sub_device.on) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index f47d80ead17..7fd5347f432 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -1,6 +1,7 @@ """Motor speed support for Xiaomi Mi Air Humidifier.""" from __future__ import annotations +import dataclasses from dataclasses import dataclass from homeassistant.components.number import NumberEntity, NumberEntityDescription @@ -108,10 +109,10 @@ NUMBER_TYPES = { key=ATTR_MOTOR_SPEED, name="Motor Speed", icon="mdi:fast-forward-outline", - unit_of_measurement="rpm", - min_value=200, - max_value=2000, - step=10, + native_unit_of_measurement="rpm", + native_min_value=200, + native_max_value=2000, + native_step=10, available_with_device_off=False, method="async_set_motor_speed", entity_category=EntityCategory.CONFIG, @@ -120,9 +121,9 @@ NUMBER_TYPES = { key=ATTR_FAVORITE_LEVEL, name="Favorite Level", icon="mdi:star-cog", - min_value=0, - max_value=17, - step=1, + native_min_value=0, + native_max_value=17, + native_step=1, method="async_set_favorite_level", entity_category=EntityCategory.CONFIG, ), @@ -130,9 +131,9 @@ NUMBER_TYPES = { key=ATTR_FAN_LEVEL, name="Fan Level", icon="mdi:fan", - min_value=1, - max_value=3, - step=1, + native_min_value=1, + native_max_value=3, + native_step=1, method="async_set_fan_level", entity_category=EntityCategory.CONFIG, ), @@ -140,9 +141,9 @@ NUMBER_TYPES = { key=ATTR_VOLUME, name="Volume", icon="mdi:volume-high", - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, method="async_set_volume", entity_category=EntityCategory.CONFIG, ), @@ -150,10 +151,10 @@ NUMBER_TYPES = { key=ATTR_OSCILLATION_ANGLE, name="Oscillation Angle", icon="mdi:angle-acute", - unit_of_measurement=DEGREE, - min_value=1, - max_value=120, - step=1, + native_unit_of_measurement=DEGREE, + native_min_value=1, + native_max_value=120, + native_step=1, method="async_set_oscillation_angle", entity_category=EntityCategory.CONFIG, ), @@ -161,10 +162,10 @@ NUMBER_TYPES = { key=ATTR_DELAY_OFF_COUNTDOWN, name="Delay Off Countdown", icon="mdi:fan-off", - unit_of_measurement=TIME_MINUTES, - min_value=0, - max_value=480, - step=1, + native_unit_of_measurement=TIME_MINUTES, + native_min_value=0, + native_max_value=480, + native_step=1, method="async_set_delay_off_countdown", entity_category=EntityCategory.CONFIG, ), @@ -172,9 +173,9 @@ NUMBER_TYPES = { key=ATTR_LED_BRIGHTNESS, name="Led Brightness", icon="mdi:brightness-6", - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, method="async_set_led_brightness", entity_category=EntityCategory.CONFIG, ), @@ -182,9 +183,9 @@ NUMBER_TYPES = { key=ATTR_LED_BRIGHTNESS_LEVEL, name="Led Brightness", icon="mdi:brightness-6", - min_value=0, - max_value=8, - step=1, + native_min_value=0, + native_max_value=8, + native_step=1, method="async_set_led_brightness_level", entity_category=EntityCategory.CONFIG, ), @@ -192,10 +193,10 @@ NUMBER_TYPES = { key=ATTR_FAVORITE_RPM, name="Favorite Motor Speed", icon="mdi:star-cog", - unit_of_measurement="rpm", - min_value=300, - max_value=2200, - step=10, + native_unit_of_measurement="rpm", + native_min_value=300, + native_max_value=2200, + native_step=10, method="async_set_favorite_rpm", entity_category=EntityCategory.CONFIG, ), @@ -273,9 +274,12 @@ async def async_setup_entry( description.key == ATTR_OSCILLATION_ANGLE and model in OSCILLATION_ANGLE_VALUES ): - description.max_value = OSCILLATION_ANGLE_VALUES[model].max_value - description.min_value = OSCILLATION_ANGLE_VALUES[model].min_value - description.step = OSCILLATION_ANGLE_VALUES[model].step + description = dataclasses.replace( + description, + native_max_value=OSCILLATION_ANGLE_VALUES[model].max_value, + native_min_value=OSCILLATION_ANGLE_VALUES[model].min_value, + native_step=OSCILLATION_ANGLE_VALUES[model].step, + ) entities.append( XiaomiNumberEntity( @@ -298,7 +302,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Initialize the generic Xiaomi attribute selector.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_value = self._extract_value_from_attribute( + self._attr_native_value = self._extract_value_from_attribute( coordinator.data, description.key ) self.entity_description = description @@ -314,18 +318,18 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): return False return super().available - async def async_set_value(self, value): + async def async_set_native_value(self, value): """Set an option of the miio device.""" method = getattr(self, self.entity_description.method) if await method(int(value)): - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() @callback def _handle_coordinator_update(self): """Fetch state from the device.""" # On state change the device doesn't provide the new state immediately. - self._attr_value = self._extract_value_from_attribute( + self._attr_native_value = self._extract_value_from_attribute( self.coordinator.data, self.entity_description.key ) self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/translations/sv.json b/homeassistant/components/xiaomi_miio/translations/sv.json new file mode 100644 index 00000000000..20e4d8c6d07 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "cloud": { + "data": { + "cloud_password": "Molnl\u00f6senord", + "cloud_username": "Molnanv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index 081c25c4342..cd312e79ceb 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -1,7 +1,7 @@ """Support for Yale Smart Alarm button.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -50,7 +50,7 @@ class YalePanicButton(YaleAlarmEntity, ButtonEntity): self._attr_name = f"{coordinator.entry.data[CONF_NAME]} {description.name}" self._attr_unique_id = f"yale_smart_alarm-{description.key}" - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" if TYPE_CHECKING: assert self.coordinator.yale, "Connection to API is missing" diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index ae5f492bc6a..a2462df41cb 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Yale Smart Alarm integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -53,9 +54,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return YaleOptionsFlowHandler(config_entry) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Yale.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/yale_smart_alarm/translations/sv.json b/homeassistant/components/yale_smart_alarm/translations/sv.json new file mode 100644 index 00000000000..8a60ea1a5dc --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 954942b2c6b..cee6253531b 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -106,7 +106,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): self.coordinator.musiccast.register_group_update_callback( self.update_all_mc_entities ) - self.coordinator.async_add_listener(self.async_schedule_check_client_list) + self.async_on_remove( + self.coordinator.async_add_listener(self.async_schedule_check_client_list) + ) async def async_will_remove_from_hass(self): """Entity being removed from hass.""" @@ -116,7 +118,6 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): self.coordinator.musiccast.remove_group_update_callback( self.update_all_mc_entities ) - self.coordinator.async_remove_listener(self.async_schedule_check_client_list) @property def should_poll(self): diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index 2648359f768..b05c47ce279 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -45,15 +45,15 @@ class NumberCapability(MusicCastCapabilityEntity, NumberEntity): ) -> None: """Initialize the number entity.""" super().__init__(coordinator, capability, zone_id) - self._attr_min_value = capability.value_range.minimum - self._attr_max_value = capability.value_range.maximum - self._attr_step = capability.value_range.step + self._attr_native_min_value = capability.value_range.minimum + self._attr_native_max_value = capability.value_range.maximum + self._attr_native_step = capability.value_range.step @property - def value(self): + def native_value(self): """Return the current value.""" return self.capability.current - async def async_set_value(self, value: float): + async def async_set_native_value(self, value: float): """Set a new value.""" await self.capability.set(value) diff --git a/homeassistant/components/yamaha_musiccast/translations/select.pt.json b/homeassistant/components/yamaha_musiccast/translations/select.pt.json new file mode 100644 index 00000000000..059993c8829 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.pt.json @@ -0,0 +1,22 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Autom\u00e1tico" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Autom\u00e1tico", + "bypass": "Desviar", + "manual": "Manual" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync_off": "Sincroniza\u00e7\u00e3o de \u00e1udio desligada", + "audio_sync_on": "Sincroniza\u00e7\u00e3o de \u00e1udio ativada", + "balanced": "Equilibrado", + "lip_sync": "Sincroniza\u00e7\u00e3o labial" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Comprimido", + "uncompressed": "Incomprimido" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 440b717fd8c..b4afedd6c51 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Yeelight integration.""" +from __future__ import annotations + import asyncio import logging from urllib.parse import urlparse @@ -9,8 +11,8 @@ from yeelight.aio import AsyncBulb from yeelight.main import get_known_models from homeassistant import config_entries, exceptions -from homeassistant.components import dhcp, ssdp, zeroconf -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components import dhcp, onboarding, ssdp, zeroconf +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -46,7 +48,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Return the options flow.""" return OptionsFlowHandler(config_entry) @@ -132,7 +134,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_discovery_confirm(self, user_input=None): """Confirm discovery.""" - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self.async_create_entry( title=async_format_model_id(self._discovered_model, self.unique_id), data={ @@ -276,7 +278,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Yeelight.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize the option flow.""" self._config_entry = config_entry diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 57d32f315ba..1032ef0d2e5 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.10", "async-upnp-client==0.31.1"], + "requirements": ["yeelight==0.7.10", "async-upnp-client==0.31.2"], "codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7eb6b0229f0..92068d1e26e 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -24,7 +24,14 @@ from .coordinator import YoLinkCoordinator SCAN_INTERVAL = timedelta(minutes=5) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SIREN, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.LOCK, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index cacba484fe9..b296e01fa56 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -18,9 +18,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_COORDINATORS, + ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, DOMAIN, ) from .coordinator import YoLinkCoordinator @@ -40,8 +42,11 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_LEAK_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_CO_SMOKE_SENSOR, ] + SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="door_state", @@ -49,14 +54,14 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.DOOR, name="State", value=lambda value: value == "open" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_DOOR_SENSOR, ), YoLinkBinarySensorEntityDescription( key="motion_state", device_class=BinarySensorDeviceClass.MOTION, name="Motion", value=lambda value: value == "alert" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_MOTION_SENSOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MOTION_SENSOR, ), YoLinkBinarySensorEntityDescription( key="leak_state", @@ -64,7 +69,28 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( icon="mdi:water", device_class=BinarySensorDeviceClass.MOISTURE, value=lambda value: value == "alert" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_LEAK_SENSOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_LEAK_SENSOR, + ), + YoLinkBinarySensorEntityDescription( + key="vibration_state", + name="Vibration", + device_class=BinarySensorDeviceClass.VIBRATION, + value=lambda value: value == "alert" if value is not None else None, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_VIBRATION_SENSOR, + ), + YoLinkBinarySensorEntityDescription( + key="co_detected", + name="Co Detected", + device_class=BinarySensorDeviceClass.CO, + value=lambda state: state.get("gasAlarm"), + exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, + ), + YoLinkBinarySensorEntityDescription( + key="smoke_detected", + name="Smoke Detected", + device_class=BinarySensorDeviceClass.SMOKE, + value=lambda state: state.get("smokeAlarm"), + exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, ), ) @@ -87,7 +113,7 @@ async def async_setup_entry( if description.exists_fn(binary_sensor_device_coordinator.device): entities.append( YoLinkBinarySensorEntity( - binary_sensor_device_coordinator, description + config_entry, binary_sensor_device_coordinator, description ) ) async_add_entities(entities) @@ -100,11 +126,12 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): def __init__( self, + config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkBinarySensorEntityDescription, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator) + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py new file mode 100644 index 00000000000..1f877571d94 --- /dev/null +++ b/homeassistant/components/yolink/climate.py @@ -0,0 +1,135 @@ +"""YoLink Thermostat.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_ON, + PRESET_ECO, + PRESET_NONE, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_COORDINATORS, ATTR_DEVICE_THERMOSTAT, DOMAIN +from .coordinator import YoLinkCoordinator +from .entity import YoLinkEntity + +YOLINK_MODEL_2_HA = { + "cool": HVACMode.COOL, + "heat": HVACMode.HEAT, + "auto": HVACMode.AUTO, + "off": HVACMode.OFF, +} + +HA_MODEL_2_YOLINK = {v: k for k, v in YOLINK_MODEL_2_HA.items()} + +YOLINK_ACTION_2_HA = { + "cool": HVACAction.COOLING, + "heat": HVACAction.HEATING, + "idle": HVACAction.IDLE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up YoLink Thermostat from a config entry.""" + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + entities = [ + YoLinkClimateEntity(config_entry, device_coordinator) + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type == ATTR_DEVICE_THERMOSTAT + ] + async_add_entities(entities) + + +class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): + """YoLink Climate Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: YoLinkCoordinator, + ) -> None: + """Init YoLink Thermostat.""" + super().__init__(config_entry, coordinator) + self._attr_unique_id = f"{coordinator.device.device_id}_climate" + self._attr_name = f"{coordinator.device.device_name} (Thermostat)" + self._attr_temperature_unit = TEMP_CELSIUS + self._attr_fan_modes = [FAN_ON, FAN_AUTO] + self._attr_min_temp = -10 + self._attr_max_temp = 50 + self._attr_hvac_modes = [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.AUTO, + HVACMode.OFF, + ] + self._attr_preset_modes = [PRESET_NONE, PRESET_ECO] + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + + @callback + def update_entity_state(self, state: dict[str, Any]) -> None: + """Update HA Entity State.""" + normal_state = state.get("state") + if normal_state is not None: + self._attr_current_temperature = normal_state.get("temperature") + self._attr_current_humidity = normal_state.get("humidity") + self._attr_target_temperature_low = normal_state.get("lowTemp") + self._attr_target_temperature_high = normal_state.get("highTemp") + self._attr_fan_mode = normal_state.get("fan") + self._attr_hvac_mode = YOLINK_MODEL_2_HA.get(normal_state.get("mode")) + self._attr_hvac_action = YOLINK_ACTION_2_HA.get(normal_state.get("running")) + eco_setting = state.get("eco") + if eco_setting is not None: + self._attr_preset_mode = ( + PRESET_NONE if eco_setting.get("mode") == "on" else PRESET_ECO + ) + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if (hvac_mode_id := HA_MODEL_2_YOLINK.get(hvac_mode)) is None: + raise ValueError(f"Received an invalid hvac mode: {hvac_mode}") + await self.call_device_api("setState", {"mode": hvac_mode_id}) + await self.coordinator.async_refresh() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + await self.call_device_api("setState", {"fan": fan_mode}) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs) -> None: + """Set temperature.""" + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if target_temp_low is not None: + await self.call_device_api("setState", {"lowTemp": target_temp_low}) + self._attr_target_temperature_low = target_temp_low + if target_temp_high is not None: + await self.call_device_api("setState", {"highTemp": target_temp_high}) + self._attr_target_temperature_high = target_temp_high + await self.coordinator.async_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + eco_params = "on" if preset_mode == PRESET_ECO else "off" + await self.call_device_api("setECO", {"mode": eco_params}) + self._attr_preset_mode = PRESET_ECO if eco_params == "on" else PRESET_NONE + self.async_write_ha_state() diff --git a/homeassistant/components/yolink/config_flow.py b/homeassistant/components/yolink/config_flow.py index 35a4c4ebea8..128cd6cb35c 100644 --- a/homeassistant/components/yolink/config_flow.py +++ b/homeassistant/components/yolink/config_flow.py @@ -1,6 +1,7 @@ """Config flow for yolink.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -30,7 +31,7 @@ class OAuth2FlowHandler( scopes = ["create"] return {"scope": " ".join(scopes)} - async def async_step_reauth(self, user_input=None) -> FlowResult: + 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"] diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 97252c5c989..f6add984dc2 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -17,5 +17,11 @@ ATTR_DEVICE_DOOR_SENSOR = "DoorSensor" ATTR_DEVICE_TH_SENSOR = "THSensor" ATTR_DEVICE_MOTION_SENSOR = "MotionSensor" ATTR_DEVICE_LEAK_SENSOR = "LeakSensor" +ATTR_DEVICE_VIBRATION_SENSOR = "VibrationSensor" ATTR_DEVICE_OUTLET = "Outlet" ATTR_DEVICE_SIREN = "Siren" +ATTR_DEVICE_LOCK = "Lock" +ATTR_DEVICE_MANIPULATOR = "Manipulator" +ATTR_DEVICE_CO_SMOKE_SENSOR = "COSmokeSensor" +ATTR_DEVICE_SWITCH = "Switch" +ATTR_DEVICE_THERMOSTAT = "Thermostat" diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 5365681739e..02f063a282a 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -3,7 +3,11 @@ from __future__ import annotations from abc import abstractmethod +from yolink.exception import YoLinkAuthFailError, YoLinkClientError + +from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -16,10 +20,12 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def __init__( self, + config_entry: ConfigEntry, coordinator: YoLinkCoordinator, ) -> None: """Init YoLink Entity.""" super().__init__(coordinator) + self.config_entry = config_entry @property def device_id(self) -> str: @@ -52,3 +58,15 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): @abstractmethod def update_entity_state(self, state: dict) -> None: """Parse and update entity state, should be overridden.""" + + async def call_device_api(self, command: str, params: dict) -> None: + """Call device Api.""" + try: + # call_device_http_api will check result, fail by raise YoLinkClientError + await self.coordinator.device.call_device_http_api(command, params) + except YoLinkAuthFailError as yl_auth_err: + self.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError(yl_auth_err) from yl_auth_err + except YoLinkClientError as yl_client_err: + self.coordinator.last_update_success = False + raise HomeAssistantError(yl_client_err) from yl_client_err diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py new file mode 100644 index 00000000000..ca340c2e762 --- /dev/null +++ b/homeassistant/components/yolink/lock.py @@ -0,0 +1,65 @@ +"""YoLink Lock.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_COORDINATORS, ATTR_DEVICE_LOCK, DOMAIN +from .coordinator import YoLinkCoordinator +from .entity import YoLinkEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up YoLink lock from a config entry.""" + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + entities = [ + YoLinkLockEntity(config_entry, device_coordinator) + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type == ATTR_DEVICE_LOCK + ] + async_add_entities(entities) + + +class YoLinkLockEntity(YoLinkEntity, LockEntity): + """YoLink Lock Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: YoLinkCoordinator, + ) -> None: + """Init YoLink Lock.""" + super().__init__(config_entry, coordinator) + self._attr_unique_id = f"{coordinator.device.device_id}_lock_state" + self._attr_name = f"{coordinator.device.device_name}(LockState)" + + @callback + def update_entity_state(self, state: dict[str, Any]) -> None: + """Update HA Entity State.""" + state_value = state.get("state") + self._attr_is_locked = ( + state_value == "locked" if state_value is not None else None + ) + self.async_write_ha_state() + + async def call_lock_state_change(self, state: str) -> None: + """Call setState api to change lock state.""" + await self.call_device_api("setState", {"state": state}) + self._attr_is_locked = state == "lock" + self.async_write_ha_state() + + async def async_lock(self, **kwargs: Any) -> None: + """Lock device.""" + await self.call_lock_state_change("lock") + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock device.""" + await self.call_lock_state_change("unlock") diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 463d8b14da4..4679c3e670b 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -20,9 +20,14 @@ from homeassistant.util import percentage from .const import ( ATTR_COORDINATORS, + ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_LEAK_SENSOR, + ATTR_DEVICE_LOCK, + ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, DOMAIN, ) from .coordinator import YoLinkCoordinator @@ -45,6 +50,37 @@ class YoLinkSensorEntityDescription( value: Callable = lambda state: state +SENSOR_DEVICE_TYPE = [ + ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_LOCK, + ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_CO_SMOKE_SENSOR, +] + +BATTERY_POWER_SENSOR = [ + ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_LEAK_SENSOR, + ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_LOCK, + ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_CO_SMOKE_SENSOR, +] + + +def cvt_battery(val: int | None) -> int | None: + """Convert battery to percentage.""" + if val is None: + return None + if val > 0: + return percentage.ordered_list_item_to_percentage([1, 2, 3, 4], val) + return 0 + + SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( YoLinkSensorEntityDescription( key="battery", @@ -52,13 +88,8 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, name="Battery", state_class=SensorStateClass.MEASUREMENT, - value=lambda value: percentage.ordered_list_item_to_percentage( - [1, 2, 3, 4], value - ) - if value is not None - else None, - exists_fn=lambda device: device.device_type - in [ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_MOTION_SENSOR], + value=cvt_battery, + exists_fn=lambda device: device.device_type in BATTERY_POWER_SENSOR, ), YoLinkSensorEntityDescription( key="humidity", @@ -78,12 +109,6 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( ), ) -SENSOR_DEVICE_TYPE = [ - ATTR_DEVICE_DOOR_SENSOR, - ATTR_DEVICE_MOTION_SENSOR, - ATTR_DEVICE_TH_SENSOR, -] - async def async_setup_entry( hass: HomeAssistant, @@ -103,6 +128,7 @@ async def async_setup_entry( if description.exists_fn(sensor_device_coordinator.device): entities.append( YoLinkSensorEntity( + config_entry, sensor_device_coordinator, description, ) @@ -117,11 +143,12 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): def __init__( self, + config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSensorEntityDescription, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator) + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 7e67dfb12f1..fd1c8e89e07 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from typing import Any from yolink.device import YoLinkDevice -from yolink.exception import YoLinkAuthFailError, YoLinkClientError from homeassistant.components.siren import ( SirenEntity, @@ -15,7 +14,6 @@ from homeassistant.components.siren import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_COORDINATORS, ATTR_DEVICE_SIREN, DOMAIN @@ -79,8 +77,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): description: YoLinkSirenEntityDescription, ) -> None: """Init YoLink Siren.""" - super().__init__(coordinator) - self.config_entry = config_entry + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" @@ -102,17 +99,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): async def call_state_change(self, state: bool) -> None: """Call setState api to change siren state.""" - try: - # call_device_http_api will check result, fail by raise YoLinkClientError - await self.coordinator.device.call_device_http_api( - "setState", {"state": {"alarm": state}} - ) - except YoLinkAuthFailError as yl_auth_err: - self.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(yl_auth_err) from yl_auth_err - except YoLinkClientError as yl_client_err: - self.coordinator.last_update_success = False - raise HomeAssistantError(yl_client_err) from yl_client_err + await self.call_device_api("setState", {"state": {"alarm": state}}) self._attr_is_on = self.entity_description.value("alert" if state else "normal") self.async_write_ha_state() diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index f16dc781a9c..03bb2a26183 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from typing import Any from yolink.device import YoLinkDevice -from yolink.exception import YoLinkAuthFailError, YoLinkClientError from homeassistant.components.switch import ( SwitchDeviceClass, @@ -15,10 +14,15 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_COORDINATORS, ATTR_DEVICE_OUTLET, DOMAIN +from .const import ( + ATTR_COORDINATORS, + ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_OUTLET, + ATTR_DEVICE_SWITCH, + DOMAIN, +) from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -29,19 +33,34 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True value: Callable[[Any], bool | None] = lambda _: None + state_key: str = "state" DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( YoLinkSwitchEntityDescription( - key="state", + key="outlet_state", device_class=SwitchDeviceClass.OUTLET, name="State", value=lambda value: value == "open" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_OUTLET], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_OUTLET, + ), + YoLinkSwitchEntityDescription( + key="manipulator_state", + name="State", + icon="mdi:pipe", + value=lambda value: value == "open" if value is not None else None, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MANIPULATOR, + ), + YoLinkSwitchEntityDescription( + key="switch_state", + name="State", + device_class=SwitchDeviceClass.SWITCH, + value=lambda value: value == "open" if value is not None else None, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_SWITCH, ), ) -DEVICE_TYPE = [ATTR_DEVICE_OUTLET] +DEVICE_TYPE = [ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SWITCH] async def async_setup_entry( @@ -49,7 +68,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up YoLink Sensor from a config entry.""" + """Set up YoLink switch from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] switch_device_coordinators = [ device_coordinator @@ -79,9 +98,8 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): coordinator: YoLinkCoordinator, description: YoLinkSwitchEntityDescription, ) -> None: - """Init YoLink Outlet.""" - super().__init__(coordinator) - self.config_entry = config_entry + """Init YoLink switch.""" + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" @@ -94,23 +112,13 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state.get(self.entity_description.key) + state.get(self.entity_description.state_key) ) self.async_write_ha_state() async def call_state_change(self, state: str) -> None: - """Call setState api to change outlet state.""" - try: - # call_device_http_api will check result, fail by raise YoLinkClientError - await self.coordinator.device.call_device_http_api( - "setState", {"state": state} - ) - except YoLinkAuthFailError as yl_auth_err: - self.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(yl_auth_err) from yl_auth_err - except YoLinkClientError as yl_client_err: - self.coordinator.last_update_success = False - raise HomeAssistantError(yl_client_err) from yl_client_err + """Call setState api to change switch state.""" + await self.call_device_api("setState", {"state": state}) self._attr_is_on = self.entity_description.value(state) self.async_write_ha_state() diff --git a/homeassistant/components/yolink/translations/es.json b/homeassistant/components/yolink/translations/es.json new file mode 100644 index 00000000000..71df6ac31e3 --- /dev/null +++ b/homeassistant/components/yolink/translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en progreso", + "authorize_url_timeout": "Tiempo de espera para generar la URL de autorizaci\u00f3n.", + "missing_configuration": "El componente no est\u00e1 configurado. Por favor, siga la documentaci\u00f3n.", + "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [compruebe la secci\u00f3n de ayuda]({docs_url})", + "oauth_error": "Se han recibido datos no v\u00e1lidos del token.", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + }, + "create_entry": { + "default": "Autentificado con \u00e9xito" + }, + "step": { + "pick_implementation": { + "title": "Elija el m\u00e9todo de autenticaci\u00f3n" + }, + "reauth_confirm": { + "description": "La integraci\u00f3n de yolink necesita volver a autenticar su cuenta", + "title": "Integraci\u00f3n de la reautenticaci\u00f3n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yolink/translations/pt-BR.json b/homeassistant/components/yolink/translations/pt-BR.json index 31a69f7ed3f..bbd59ef845f 100644 --- a/homeassistant/components/yolink/translations/pt-BR.json +++ b/homeassistant/components/yolink/translations/pt-BR.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "A conta j\u00e1 est\u00e1 configurada", - "already_in_progress": "A configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "already_configured": "A conta j\u00e1 foi configurada", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "Nenhuma URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre este erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", - "oauth_error": "Dados de token inv\u00e1lidos recebidos.", + "no_url_available": "N\u00e3o h\u00e1 URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", + "oauth_error": "Dados de token recebidos inv\u00e1lidos.", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "create_entry": { diff --git a/homeassistant/components/yolink/translations/sv.json b/homeassistant/components/yolink/translations/sv.json new file mode 100644 index 00000000000..3c6db089e0e --- /dev/null +++ b/homeassistant/components/yolink/translations/sv.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats", + "already_in_progress": "Konfigurationsfl\u00f6det p\u00e5g\u00e5r redan", + "authorize_url_timeout": "Timeout vid generering av en auktoriserings-URL.", + "missing_configuration": "Komponenten har inte konfigurerats. F\u00f6lj dokumentationen.", + "no_url_available": "Ingen webbadress tillg\u00e4nglig. F\u00f6r information om detta fel, [kolla hj\u00e4lpavsnittet]({docs_url})", + "oauth_error": "Mottog ogiltiga tokendata.", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "create_entry": { + "default": "Autentiserats" + }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + }, + "reauth_confirm": { + "title": "\u00c5terautenticera integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 6a5d7ccdf81..6910955fcf7 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -14,7 +14,14 @@ from homeassistant.components.weather import ( PLATFORM_SCHEMA, WeatherEntity, ) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + PRESSURE_HPA, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -80,6 +87,10 @@ def setup_platform( class ZamgWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, zamg_data, stationname=None): """Initialise the platform with a data instance and station name.""" self.zamg_data = zamg_data @@ -104,17 +115,12 @@ class ZamgWeather(WeatherEntity): return ATTRIBUTION @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" return self.zamg_data.get_data(ATTR_WEATHER_TEMPERATURE) @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.zamg_data.get_data(ATTR_WEATHER_PRESSURE) @@ -124,7 +130,7 @@ class ZamgWeather(WeatherEntity): return self.zamg_data.get_data(ATTR_WEATHER_HUMIDITY) @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self.zamg_data.get_data(ATTR_WEATHER_WIND_SPEED) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8cfc0698dc4..8061be2cf8a 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.38.6"], + "requirements": ["zeroconf==0.38.7"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json index 4fee44ffcac..d382fc26ab0 100644 --- a/homeassistant/components/zestimate/manifest.json +++ b/homeassistant/components/zestimate/manifest.json @@ -2,7 +2,7 @@ "domain": "zestimate", "name": "Zestimate", "documentation": "https://www.home-assistant.io/integrations/zestimate", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index ef616a8f894..ee37a345e17 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -1,5 +1,8 @@ """Alarm control panels on Zigbee Home Automation networks.""" +from __future__ import annotations + import functools +from typing import TYPE_CHECKING from zigpy.zcl.clusters.security import IasAce @@ -38,9 +41,11 @@ from .core.const import ( ) from .core.helpers import async_get_zha_config_value from .core.registries import ZHA_ENTITIES -from .core.typing import ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.device import ZHADevice + STRICT_MATCH = functools.partial( ZHA_ENTITIES.strict_match, Platform.ALARM_CONTROL_PANEL ) @@ -76,6 +81,7 @@ async def async_setup_entry( class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Entity for ZHA alarm control devices.""" + _attr_code_format = CodeFormat.TEXT _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -83,7 +89,7 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.TRIGGER ) - def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): + def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs): """Initialize the ZHA alarm control device.""" super().__init__(unique_id, zha_device, channels, **kwargs) cfg_entry = zha_device.gateway.config_entry @@ -98,7 +104,7 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3 ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -114,45 +120,35 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): self.async_write_ha_state() @property - def code_format(self): - """Regex for code format or None if no code is required.""" - return CodeFormat.TEXT - - @property - def changed_by(self): - """Last change triggered by.""" - return None - - @property - def code_arm_required(self): + def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self._channel.code_required_arm_actions - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._channel.arm(IasAce.ArmMode.Disarm, code, 0) self.async_write_ha_state() - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._channel.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) self.async_write_ha_state() - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._channel.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) self.async_write_ha_state() - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" self._channel.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) self.async_write_ha_state() - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" self.async_write_ha_state() @property - def state(self): + def state(self) -> str | None: """Return the state of the entity.""" return IAS_ACE_STATE_MAP.get(self._channel.armed_state) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index faf8ccc5053..cc4dd45689e 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -60,6 +60,7 @@ from .core.const import ( ZHA_CHANNEL_MSG, ZHA_CONFIG_SCHEMAS, ) +from .core.gateway import EntityReference from .core.group import GroupMember from .core.helpers import ( async_cluster_exists, @@ -68,11 +69,11 @@ from .core.helpers import ( get_matched_clusters, qr_to_install_code, ) -from .core.typing import ZhaDeviceType if TYPE_CHECKING: from homeassistant.components.websocket_api.connection import ActiveConnection + from .core.device import ZHADevice from .core.gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) @@ -316,6 +317,22 @@ async def websocket_get_devices( connection.send_result(msg[ID], devices) +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +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) + return entry.original_name if entry else None + + @websocket_api.require_admin @websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) @websocket_api.async_response @@ -329,19 +346,17 @@ async def websocket_get_groupable_devices( groupable_devices = [] for device in devices: - entity_refs = zha_gateway.device_registry.get(device.ieee) + entity_refs = zha_gateway.device_registry[device.ieee] for ep_id in device.async_get_groupable_endpoints(): groupable_devices.append( { "endpoint_id": ep_id, "entities": [ { - "name": zha_gateway.ha_entity_registry.async_get( - entity_ref.reference_id - ).name, - "original_name": zha_gateway.ha_entity_registry.async_get( - entity_ref.reference_id - ).original_name, + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), } for entity_ref in entity_refs if list(entity_ref.cluster_channels.values())[ @@ -441,6 +456,7 @@ async def websocket_add_group( group_id: int | None = msg.get(GROUP_ID) members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) + assert group connection.send_result(msg[ID], group.group_info) @@ -544,7 +560,7 @@ async def websocket_reconfigure_node( """Reconfigure a ZHA nodes entities by its ieee address.""" zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = msg[ATTR_IEEE] - device: ZhaDeviceType = zha_gateway.get_device(ieee) + device: ZHADevice | None = zha_gateway.get_device(ieee) async def forward_messages(data): """Forward events to websocket.""" @@ -562,6 +578,7 @@ async def websocket_reconfigure_node( connection.subscriptions[msg["id"]] = async_cleanup _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + assert device hass.async_create_task(device.async_configure()) @@ -890,6 +907,7 @@ async def websocket_bind_group( group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) + assert source_device await source_device.async_bind_to_group(group_id, bindings) @@ -912,6 +930,7 @@ async def websocket_unbind_group( group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) + assert source_device await source_device.async_unbind_from_group(group_id, bindings) @@ -926,6 +945,8 @@ async def async_binding_operation( source_device = zha_gateway.get_device(source_ieee) target_device = zha_gateway.get_device(target_ieee) + assert source_device + assert target_device clusters_to_bind = await get_matched_clusters(source_device, target_device) zdo = source_device.device.zdo @@ -982,7 +1003,7 @@ async def websocket_get_configuration( return cv.custom_serializer(schema) - data = {"schemas": {}, "data": {}} + data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} for section, schema in ZHA_CONFIG_SCHEMAS.items(): if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( hass, IasAce.cluster_id @@ -1069,7 +1090,7 @@ def async_load_api(hass: HomeAssistant) -> None: """Remove a node from the network.""" zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZhaDeviceType = zha_gateway.get_device(ieee) + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) if zha_device is not None and ( zha_device.is_coordinator and zha_device.ieee == zha_gateway.application_controller.ieee diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 954c60fa895..709515d7ca2 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -1,4 +1,6 @@ """Binary sensors on Zigbee Home Automation networks.""" +from __future__ import annotations + import functools from homeassistant.components.binary_sensor import ( @@ -60,7 +62,7 @@ async def async_setup_entry( class BinarySensor(ZhaEntity, BinarySensorEntity): """ZHA BinarySensor.""" - SENSOR_ATTR = None + SENSOR_ATTR: str | None = None def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA binary sensor.""" @@ -161,7 +163,7 @@ class IASZone(BinarySensor): SENSOR_ATTR = "zone_status" @property - def device_class(self) -> str: + def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" return CLASS_MAPPING.get(self._channel.cluster.get("zone_type")) @@ -184,3 +186,11 @@ class FrostLock(BinarySensor, id_suffix="frost_lock"): SENSOR_ATTR = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): + """ZHA BinarySensor.""" + + SENSOR_ATTR = "replace_filter" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 9f241795267..0f98bfaad51 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -4,7 +4,7 @@ from __future__ import annotations import abc import functools import logging -from typing import Any +from typing import TYPE_CHECKING, Any import zigpy.exceptions from zigpy.zcl.foundation import Status @@ -20,9 +20,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import CHANNEL_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + + MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BUTTON) CONFIG_DIAGNOSTIC_MATCH = functools.partial( ZHA_ENTITIES.config_diagnostic_match, Platform.BUTTON @@ -55,18 +59,18 @@ async def async_setup_entry( class ZHAButton(ZhaEntity, ButtonEntity): """Defines a ZHA button.""" - _command_name: str = None + _command_name: str def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this button.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] @abc.abstractmethod def get_args(self) -> list[Any]: @@ -87,8 +91,8 @@ class ZHAIdentifyButton(ZHAButton): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -114,19 +118,19 @@ class ZHAIdentifyButton(ZHAButton): class ZHAAttributeButton(ZhaEntity, ButtonEntity): """Defines a ZHA button, which stes value to an attribute.""" - _attribute_name: str = None + _attribute_name: str _attribute_value: Any = None def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this button.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] async def async_press(self) -> None: """Write attribute with defined value.""" @@ -150,9 +154,21 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): }, ) class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): - """Defines a ZHA identify button.""" + """Defines a ZHA frost lock reset button.""" _attribute_name = "frost_lock_reset" _attribute_value = 0 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +class NoPresenceStatusResetButton( + ZHAAttributeButton, id_suffix="reset_no_presence_status" +): + """Defines a ZHA no presence status reset button.""" + + _attribute_name = "reset_no_presence_status" + _attribute_value = 1 + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 291e8413e16..d8b2f0db3af 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -73,16 +73,16 @@ MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.CLIMATE) RUNNING_MODE = {0x00: HVACMode.OFF, 0x03: HVACMode.COOL, 0x04: HVACMode.HEAT} SEQ_OF_OPERATION = { - 0x00: (HVACMode.OFF, HVACMode.COOL), # cooling only - 0x01: (HVACMode.OFF, HVACMode.COOL), # cooling with reheat - 0x02: (HVACMode.OFF, HVACMode.HEAT), # heating only - 0x03: (HVACMode.OFF, HVACMode.HEAT), # heating with reheat + 0x00: [HVACMode.OFF, HVACMode.COOL], # cooling only + 0x01: [HVACMode.OFF, HVACMode.COOL], # cooling with reheat + 0x02: [HVACMode.OFF, HVACMode.HEAT], # heating only + 0x03: [HVACMode.OFF, HVACMode.HEAT], # heating with reheat # cooling and heating 4-pipes - 0x04: (HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT), + 0x04: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], # cooling and heating 4-pipes - 0x05: (HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT), - 0x06: (HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF), # centralite specific - 0x07: (HVACMode.HEAT_COOL, HVACMode.OFF), # centralite specific + 0x05: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], + 0x06: [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF], # centralite specific + 0x07: [HVACMode.HEAT_COOL, HVACMode.OFF], # centralite specific } HVAC_MODE_2_SYSTEM = { @@ -268,7 +268,7 @@ class Thermostat(ZhaEntity, ClimateEntity): return PRECISION_TENTHS @property - def preset_mode(self) -> str | None: + def preset_mode(self) -> str: """Return current preset mode.""" return self._preset @@ -389,7 +389,7 @@ class Thermostat(ZhaEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" - if fan_mode not in self.fan_modes: + if not self.fan_modes or fan_mode not in self.fan_modes: self.warning("Unsupported '%s' fan mode", fan_mode) return @@ -415,8 +415,8 @@ class Thermostat(ZhaEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if preset_mode not in self.preset_modes: - self.debug("preset mode '%s' is not supported", preset_mode) + if not self.preset_modes or preset_mode not in self.preset_modes: + self.debug("Preset mode '%s' is not supported", preset_mode) return if self.preset_mode not in ( @@ -505,7 +505,7 @@ class SinopeTechnologiesThermostat(Thermostat): self._manufacturer_ch = self.cluster_channels["sinope_manufacturer_specific"] @property - def _rm_rs_action(self) -> str | None: + def _rm_rs_action(self) -> HVACAction: """Return the current HVAC action based on running mode and running state.""" running_mode = self._thrm.running_mode diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index e116954cdcb..69da95e8528 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from homeassistant.components import usb, zeroconf +from homeassistant.components import onboarding, usb, zeroconf from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult @@ -36,6 +36,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize flow instance.""" self._device_path = None + self._device_settings = None self._radio_type = None self._title = None @@ -167,11 +168,10 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): radio_type = discovery_info.properties.get("radio_type") or local_name node_name = local_name[: -len(".local")] host = discovery_info.host + port = discovery_info.port if local_name.startswith("tube") or "efr32" in local_name: # This is hard coded to work with legacy devices port = 6638 - else: - port = discovery_info.port device_path = f"socket://{host}:{port}" if current_entry := await self.async_set_unique_id(node_name): @@ -242,6 +242,54 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_hardware(self, data=None): + """Handle hardware flow.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if not data: + return self.async_abort(reason="invalid_hardware_data") + if data.get("radio_type") != "efr32": + return self.async_abort(reason="invalid_hardware_data") + self._radio_type = RadioType.ezsp.name + app_cls = RadioType[self._radio_type].controller + + schema = { + vol.Required( + CONF_DEVICE_PATH, default=self._device_path or vol.UNDEFINED + ): str + } + radio_schema = app_cls.SCHEMA_DEVICE.schema + assert not isinstance(radio_schema, vol.Schema) + + for param, value in radio_schema.items(): + if param in SUPPORTED_PORT_SETTINGS: + schema[param] = value + try: + self._device_settings = vol.Schema(schema)(data.get("port")) + except vol.Invalid: + return self.async_abort(reason="invalid_hardware_data") + + self._title = data.get("name", data["port"]["path"]) + + self._set_confirm_only() + return await self.async_step_confirm_hardware() + + async def async_step_confirm_hardware(self, user_input=None): + """Confirm a hardware discovery.""" + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + return self.async_create_entry( + title=self._title, + data={ + CONF_DEVICE: self._device_settings, + CONF_RADIO_TYPE: self._radio_type, + }, + ) + + return self.async_show_form( + step_id="confirm_hardware", + description_placeholders={CONF_NAME: self._title}, + ) + async def detect_radios(dev_path: str) -> dict[str, Any] | None: """Probe all radio types on the device port.""" diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a0df976486f..9042856b456 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -163,7 +163,7 @@ class Channels: def zha_send_event(self, event_data: dict[str, str | int]) -> None: """Relay events to hass.""" self.zha_device.hass.bus.async_fire( - "zha_event", + const.ZHA_EVENT, { const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee), const.ATTR_UNIQUE_ID: self.unique_id, @@ -221,7 +221,7 @@ class ChannelPool: return self._channels.zha_device.is_mains_powered @property - def manufacturer(self) -> str | None: + def manufacturer(self) -> str: """Return device manufacturer.""" return self._channels.zha_device.manufacturer @@ -236,7 +236,7 @@ class ChannelPool: return self._channels.zha_device.hass @property - def model(self) -> str | None: + def model(self) -> str: """Return device model.""" return self._channels.zha_device.model @@ -370,7 +370,7 @@ class ChannelPool: return [self.all_channels[chan_id] for chan_id in (available - claimed)] @callback - def zha_send_event(self, event_data: dict[str, str | int]) -> None: + def zha_send_event(self, event_data: dict[str, Any]) -> None: """Relay events to hass.""" self._channels.zha_send_event( { diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 7ba28a52116..ae5980cd630 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -5,9 +5,10 @@ import asyncio from enum import Enum from functools import partialmethod, wraps import logging -from typing import Any +from typing import TYPE_CHECKING, Any, TypedDict import zigpy.exceptions +import zigpy.zcl from zigpy.zcl.foundation import ( CommandSchema, ConfigureReportingResponseRecord, @@ -19,7 +20,6 @@ from homeassistant.const import ATTR_COMMAND from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from .. import typing as zha_typing from ..const import ( ATTR_ARGS, ATTR_ATTRIBUTE_ID, @@ -40,9 +40,22 @@ from ..const import ( ) from ..helpers import LogMixin, retryable_req, safe_read +if TYPE_CHECKING: + from . import ChannelPool + _LOGGER = logging.getLogger(__name__) +class AttrReportConfig(TypedDict, total=True): + """Configuration to report for the attributes.""" + + # Could be either an attribute name or attribute id + attr: str | int + # The config for the attribute reporting configuration consists of a tuple for + # (minimum_reported_time_interval_s, maximum_reported_time_interval_s, value_delta) + config: tuple[int, int, int | float] + + def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] @@ -96,7 +109,7 @@ class ChannelStatus(Enum): class ZigbeeChannel(LogMixin): """Base channel for a Zigbee cluster.""" - REPORT_CONFIG: tuple[dict[int | str, tuple[int, int, int | float]]] = () + REPORT_CONFIG: tuple[AttrReportConfig, ...] = () BIND: bool = True # Dict of attributes to read on channel initialization. @@ -104,9 +117,7 @@ class ZigbeeChannel(LogMixin): # attribute read is acceptable. ZCL_INIT_ATTRS: dict[int | str, bool] = {} - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize ZigbeeChannel.""" self._generic_id = f"channel_0x{cluster.cluster_id:04x}" self._ch_pool = ch_pool @@ -126,7 +137,7 @@ class ZigbeeChannel(LogMixin): self.value_attribute = attr self._status = ChannelStatus.CREATED self._cluster.add_listener(self) - self.data_cache = {} + self.data_cache: dict[str, Enum] = {} @property def id(self) -> str: @@ -267,7 +278,7 @@ class ZigbeeChannel(LogMixin): ) def _configure_reporting_status( - self, attrs: dict[int | str, tuple], res: list | tuple + self, attrs: dict[int | str, tuple[int, int, float | int]], res: list | tuple ) -> None: """Parse configure reporting result.""" if isinstance(res, (Exception, ConfigureReportingResponseRecord)): @@ -293,10 +304,10 @@ class ZigbeeChannel(LogMixin): for r in res if r.status != Status.SUCCESS ] - attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs} + attributes = {self.cluster.attributes.get(r, [r])[0] for r in attrs} self.debug( "Successfully configured reporting for '%s' on '%s' cluster", - attrs - set(failed), + attributes - set(failed), self.name, ) self.debug( @@ -382,6 +393,7 @@ class ZigbeeChannel(LogMixin): def zha_send_event(self, command: str, arg: list | dict | CommandSchema) -> None: """Relay events to hass.""" + args: list | dict if isinstance(arg, CommandSchema): args = [a for a in arg if a is not None] params = arg.as_dict() diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index bf50c8fc4ba..de2dcaf38e9 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -5,7 +5,7 @@ from homeassistant.core import callback from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED -from .base import ClientChannel, ZigbeeChannel +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.DoorLock.cluster_id) @@ -13,7 +13,9 @@ class DoorLockChannel(ZigbeeChannel): """Door lock channel.""" _value_attribute = 0 - REPORT_CONFIG = ({"attr": "lock_state", "config": REPORT_CONFIG_IMMEDIATE},) + REPORT_CONFIG = ( + AttrReportConfig(attr="lock_state", config=REPORT_CONFIG_IMMEDIATE), + ) async def async_update(self): """Retrieve latest state.""" @@ -121,7 +123,9 @@ class WindowCovering(ZigbeeChannel): _value_attribute = 8 REPORT_CONFIG = ( - {"attr": "current_position_lift_percentage", "config": REPORT_CONFIG_IMMEDIATE}, + AttrReportConfig( + attr="current_position_lift_percentage", config=REPORT_CONFIG_IMMEDIATE + ), ) async def async_update(self): diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 8b67c81db44..b2870d84e15 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -3,17 +3,18 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine -from typing import Any +from typing import TYPE_CHECKING, Any import zigpy.exceptions import zigpy.types as t +import zigpy.zcl from zigpy.zcl.clusters import general from zigpy.zcl.foundation import Status from homeassistant.core import callback from homeassistant.helpers.event import async_call_later -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_BATTERY_SAVE, @@ -26,7 +27,10 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from .base import ClientChannel, ZigbeeChannel, parse_and_log_command +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel, parse_and_log_command + +if TYPE_CHECKING: + from . import ChannelPool @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Alarms.cluster_id) @@ -38,7 +42,9 @@ class Alarms(ZigbeeChannel): class AnalogInput(ZigbeeChannel): """Analog Input channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) @@ -46,7 +52,9 @@ class AnalogInput(ZigbeeChannel): class AnalogOutput(ZigbeeChannel): """Analog Output channel.""" - REPORT_CONFIG = ({"attr": "present_value", "config": REPORT_CONFIG_DEFAULT},) + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) ZCL_INIT_ATTRS = { "min_present_value": True, "max_present_value": True, @@ -115,7 +123,9 @@ class AnalogOutput(ZigbeeChannel): class AnalogValue(ZigbeeChannel): """Analog Value channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.ApplianceControl.cluster_id) @@ -147,21 +157,27 @@ class BasicChannel(ZigbeeChannel): class BinaryInput(ZigbeeChannel): """Binary Input channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryOutput.cluster_id) class BinaryOutput(ZigbeeChannel): """Binary Output channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryValue.cluster_id) class BinaryValue(ZigbeeChannel): """Binary Value channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Commissioning.cluster_id) @@ -173,12 +189,12 @@ class Commissioning(ZigbeeChannel): class DeviceTemperature(ZigbeeChannel): """Device Temperature channel.""" - REPORT_CONFIG = [ + REPORT_CONFIG = ( { "attr": "current_temperature", "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), - } - ] + }, + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.GreenPowerProxy.cluster_id) @@ -221,7 +237,7 @@ class LevelControlChannel(ZigbeeChannel): """Channel for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 - REPORT_CONFIG = ({"attr": "current_level", "config": REPORT_CONFIG_ASAP},) + REPORT_CONFIG = (AttrReportConfig(attr="current_level", config=REPORT_CONFIG_ASAP),) ZCL_INIT_ATTRS = { "on_off_transition_time": True, "on_level": True, @@ -271,21 +287,27 @@ class LevelControlChannel(ZigbeeChannel): class MultistateInput(ZigbeeChannel): """Multistate Input channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateOutput.cluster_id) class MultistateOutput(ZigbeeChannel): """Multistate Output channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateValue.cluster_id) class MultistateValue(ZigbeeChannel): """Multistate Value channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.CLIENT_CHANNELS_REGISTRY.register(general.OnOff.cluster_id) @@ -299,14 +321,12 @@ class OnOffChannel(ZigbeeChannel): """Channel for the OnOff Zigbee cluster.""" ON_OFF = 0 - REPORT_CONFIG = ({"attr": "on_off", "config": REPORT_CONFIG_IMMEDIATE},) + REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) ZCL_INIT_ATTRS = { "start_up_on_off": True, } - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize OnOffChannel.""" super().__init__(cluster, ch_pool) self._off_listener = None @@ -388,9 +408,9 @@ class OnOffConfiguration(ZigbeeChannel): """OnOff Configuration channel.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Ota.cluster_id) -class Ota(ZigbeeChannel): +@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id) +class Ota(ClientChannel): """OTA Channel.""" BIND: bool = False @@ -407,6 +427,7 @@ class Ota(ZigbeeChannel): signal_id = self._ch_pool.unique_id.split("-")[0] if cmd_name == "query_next_image": + assert args self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) @@ -470,8 +491,10 @@ class PowerConfigurationChannel(ZigbeeChannel): """Channel for the zigbee power configuration cluster.""" REPORT_CONFIG = ( - {"attr": "battery_voltage", "config": REPORT_CONFIG_BATTERY_SAVE}, - {"attr": "battery_percentage_remaining", "config": REPORT_CONFIG_BATTERY_SAVE}, + AttrReportConfig(attr="battery_voltage", config=REPORT_CONFIG_BATTERY_SAVE), + AttrReportConfig( + attr="battery_percentage_remaining", config=REPORT_CONFIG_BATTERY_SAVE + ), ) def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index e1019ed31bf..69295ef6f81 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -12,7 +12,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import ZigbeeChannel +from .base import AttrReportConfig, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -63,15 +63,15 @@ class ElectricalMeasurementChannel(ZigbeeChannel): POWER_QUALITY_MEASUREMENT = 256 REPORT_CONFIG = ( - {"attr": "active_power", "config": REPORT_CONFIG_OP}, - {"attr": "active_power_max", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "apparent_power", "config": REPORT_CONFIG_OP}, - {"attr": "rms_current", "config": REPORT_CONFIG_OP}, - {"attr": "rms_current_max", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "rms_voltage", "config": REPORT_CONFIG_OP}, - {"attr": "rms_voltage_max", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "ac_frequency", "config": REPORT_CONFIG_OP}, - {"attr": "ac_frequency_max", "config": REPORT_CONFIG_DEFAULT}, + AttrReportConfig(attr="active_power", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="active_power_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="apparent_power", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="rms_current", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="rms_current_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="rms_voltage", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="rms_voltage_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="ac_frequency", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="ac_frequency_max", config=REPORT_CONFIG_DEFAULT), ) ZCL_INIT_ATTRS = { "ac_current_divisor": True, @@ -102,7 +102,7 @@ class ElectricalMeasurementChannel(ZigbeeChannel): for attr, value in result.items(): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - self.cluster.attridx.get(attr, attr), + self.cluster.find_attribute(attr).id, attr, value, ) @@ -158,7 +158,11 @@ class ElectricalMeasurementChannel(ZigbeeChannel): return None meas_type = self.MeasurementType(meas_type) - return ", ".join(m.name for m in self.MeasurementType if m in meas_type) + return ", ".join( + m.name + for m in self.MeasurementType + if m in meas_type and m.name is not None + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 5b102d062cb..4b4909299b1 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -22,7 +22,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import ZigbeeChannel +from .base import AttrReportConfig, ZigbeeChannel AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) @@ -41,7 +41,7 @@ class FanChannel(ZigbeeChannel): _value_attribute = 0 - REPORT_CONFIG = ({"attr": "fan_mode", "config": REPORT_CONFIG_OP},) + REPORT_CONFIG = (AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_OP),) ZCL_INIT_ATTRS = {"fan_mode_sequence": True} @property @@ -90,17 +90,25 @@ class ThermostatChannel(ZigbeeChannel): """Thermostat channel.""" REPORT_CONFIG = ( - {"attr": "local_temperature", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE}, - {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + AttrReportConfig(attr="local_temperature", config=REPORT_CONFIG_CLIMATE), + AttrReportConfig( + attr="occupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig( + attr="occupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig( + attr="unoccupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig( + attr="unoccupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig(attr="running_mode", config=REPORT_CONFIG_CLIMATE), + AttrReportConfig(attr="running_state", config=REPORT_CONFIG_CLIMATE_DEMAND), + AttrReportConfig(attr="system_mode", config=REPORT_CONFIG_CLIMATE), + AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_CLIMATE_DISCRETE), + AttrReportConfig(attr="pi_cooling_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), + AttrReportConfig(attr="pi_heating_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), ) ZCL_INIT_ATTRS: dict[int | str, bool] = { "abs_min_heat_setpoint_limit": True, @@ -285,6 +293,7 @@ class ThermostatChannel(ZigbeeChannel): return bool(self.occupancy) except ZigbeeException as ex: self.debug("Couldn't read 'occupancy' attribute: %s", ex) + return None async def write_attributes(self, data, **kwargs): """Write attributes helper.""" diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 1dbf1d201c8..99e6101b0bd 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -7,7 +7,7 @@ from zigpy.zcl.clusters import lighting from .. import registries from ..const import REPORT_CONFIG_DEFAULT -from .base import ClientChannel, ZigbeeChannel +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register(lighting.Ballast.cluster_id) @@ -29,13 +29,14 @@ class ColorChannel(ZigbeeChannel): CAPABILITIES_COLOR_TEMP = 0x10 UNSUPPORTED_ATTRIBUTE = 0x86 REPORT_CONFIG = ( - {"attr": "current_x", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "current_y", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "color_temperature", "config": REPORT_CONFIG_DEFAULT}, + AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT), ) MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 ZCL_INIT_ATTRS = { + "color_mode": False, "color_temp_physical_min": True, "color_temp_physical_max": True, "color_capabilities": True, @@ -51,6 +52,11 @@ class ColorChannel(ZigbeeChannel): return self.CAPABILITIES_COLOR_XY | self.CAPABILITIES_COLOR_TEMP return self.CAPABILITIES_COLOR_XY + @property + def color_mode(self) -> int | None: + """Return cached value of the color_mode attribute.""" + return self.cluster.get("color_mode") + @property def color_loop_active(self) -> int | None: """Return cached value of the color_loop_active attribute.""" diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 3bcab38e026..943d13a57d6 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -1,20 +1,31 @@ """Manufacturer specific channels module for Zigbee Home Automation.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING, Any + +from zigpy.exceptions import ZigbeeException +import zigpy.zcl from homeassistant.core import callback -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( ATTR_ATTRIBUTE_ID, ATTR_ATTRIBUTE_NAME, ATTR_VALUE, REPORT_CONFIG_ASAP, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, SIGNAL_ATTR_UPDATED, UNKNOWN, ) -from .base import ClientChannel, ZigbeeChannel +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel + +if TYPE_CHECKING: + from . import ChannelPool _LOGGER = logging.getLogger(__name__) @@ -23,12 +34,12 @@ _LOGGER = logging.getLogger(__name__) class SmartThingsHumidity(ZigbeeChannel): """Smart Things Humidity channel.""" - REPORT_CONFIG = [ + REPORT_CONFIG = ( { "attr": "measured_value", "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), - } - ] + }, + ) @registries.CHANNEL_ONLY_CLUSTERS.register(0xFD00) @@ -36,7 +47,7 @@ class SmartThingsHumidity(ZigbeeChannel): class OsramButton(ZigbeeChannel): """Osram button channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () @registries.CHANNEL_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) @@ -44,7 +55,7 @@ class OsramButton(ZigbeeChannel): class PhillipsRemote(ZigbeeChannel): """Phillips remote channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () @registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0) @@ -52,19 +63,24 @@ class PhillipsRemote(ZigbeeChannel): class OppleRemote(ZigbeeChannel): """Opple button channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize Opple channel.""" super().__init__(cluster, ch_pool) if self.cluster.endpoint.model == "lumi.motion.ac02": - self.ZCL_INIT_ATTRS = { # pylint: disable=C0103 + self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name "detection_interval": True, "motion_sensitivity": True, "trigger_indicator": True, } + elif self.cluster.endpoint.model == "lumi.motion.ac01": + self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + "presence": True, + "monitoring_mode": True, + "motion_sensitivity": True, + "approach_distance": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" @@ -81,12 +97,12 @@ class OppleRemote(ZigbeeChannel): class SmartThingsAcceleration(ZigbeeChannel): """Smart Things Acceleration channel.""" - REPORT_CONFIG = [ - {"attr": "acceleration", "config": REPORT_CONFIG_ASAP}, - {"attr": "x_axis", "config": REPORT_CONFIG_ASAP}, - {"attr": "y_axis", "config": REPORT_CONFIG_ASAP}, - {"attr": "z_axis", "config": REPORT_CONFIG_ASAP}, - ] + REPORT_CONFIG = ( + AttrReportConfig(attr="acceleration", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="x_axis", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="y_axis", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="z_axis", config=REPORT_CONFIG_ASAP), + ) @callback def attribute_updated(self, attrid, value): @@ -115,4 +131,57 @@ class SmartThingsAcceleration(ZigbeeChannel): class InovelliCluster(ClientChannel): """Inovelli Button Press Event channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () + + +@registries.CHANNEL_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +class IkeaAirPurifierChannel(ZigbeeChannel): + """IKEA Air Purifier channel.""" + + REPORT_CONFIG = ( + AttrReportConfig(attr="filter_run_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="replace_filter", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="filter_life_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="disable_led", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="air_quality_25pm", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="child_lock", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_speed", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="device_run_time", config=REPORT_CONFIG_DEFAULT), + ) + + @property + def fan_mode(self) -> int | None: + """Return current fan mode.""" + return self.cluster.get("fan_mode") + + @property + def fan_mode_sequence(self) -> int | None: + """Return possible fan mode speeds.""" + return self.cluster.get("fan_mode_sequence") + + async def async_set_speed(self, value) -> None: + """Set the speed of the fan.""" + + try: + await self.cluster.write_attributes({"fan_mode": value}) + except ZigbeeException as ex: + self.error("Could not set speed: %s", ex) + return + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self.get_attribute_value("fan_mode", from_cache=False) + + @callback + def attribute_updated(self, attrid: int, value: Any) -> None: + """Handle attribute update from fan cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + ) + if attr_name == "fan_mode": + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 093c04245c4..fa6f9c07dee 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -8,14 +8,16 @@ from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) -from .base import ZigbeeChannel +from .base import AttrReportConfig, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id) class FlowMeasurement(ZigbeeChannel): """Flow Measurement channel.""" - REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -24,7 +26,9 @@ class FlowMeasurement(ZigbeeChannel): class IlluminanceLevelSensing(ZigbeeChannel): """Illuminance Level Sensing channel.""" - REPORT_CONFIG = [{"attr": "level_status", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="level_status", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -33,57 +37,63 @@ class IlluminanceLevelSensing(ZigbeeChannel): class IlluminanceMeasurement(ZigbeeChannel): """Illuminance Measurement channel.""" - REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.OccupancySensing.cluster_id) class OccupancySensing(ZigbeeChannel): """Occupancy Sensing channel.""" - REPORT_CONFIG = [{"attr": "occupancy", "config": REPORT_CONFIG_IMMEDIATE}] + REPORT_CONFIG = ( + AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id) class PressureMeasurement(ZigbeeChannel): """Pressure measurement channel.""" - REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.RelativeHumidity.cluster_id) class RelativeHumidity(ZigbeeChannel): """Relative Humidity measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.SoilMoisture.cluster_id) class SoilMoisture(ZigbeeChannel): """Soil Moisture measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.LeafWetness.cluster_id) class LeafWetness(ZigbeeChannel): """Leaf Wetness measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -92,12 +102,12 @@ class LeafWetness(ZigbeeChannel): class TemperatureMeasurement(ZigbeeChannel): """Temperature measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -106,12 +116,12 @@ class TemperatureMeasurement(ZigbeeChannel): class CarbonMonoxideConcentration(ZigbeeChannel): """Carbon Monoxide measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -120,12 +130,24 @@ class CarbonMonoxideConcentration(ZigbeeChannel): class CarbonDioxideConcentration(ZigbeeChannel): """Carbon Dioxide measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) + + +@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PM25.cluster_id) +class PM25(ZigbeeChannel): + """Particulate Matter 2.5 microns or less measurement channel.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -134,9 +156,9 @@ class CarbonDioxideConcentration(ZigbeeChannel): class FormaldehydeConcentration(ZigbeeChannel): """Formaldehyde measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 0e1e0f8e8a3..9463a0351c1 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -7,14 +7,17 @@ https://home-assistant.io/integrations/zha/ from __future__ import annotations import asyncio +from collections.abc import Callable +from typing import TYPE_CHECKING, Any from zigpy.exceptions import ZigbeeException +import zigpy.zcl from zigpy.zcl.clusters import security from zigpy.zcl.clusters.security import IasAce as AceCluster from homeassistant.core import callback -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( SIGNAL_ATTR_UPDATED, WARNING_DEVICE_MODE_EMERGENCY, @@ -23,9 +26,11 @@ from ..const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) -from ..typing import CALLABLE_T from .base import ChannelStatus, ZigbeeChannel +if TYPE_CHECKING: + from . import ChannelPool + IAS_ACE_ARM = 0x0000 # ("arm", (t.enum8, t.CharacterString, t.uint8_t), False), IAS_ACE_BYPASS = 0x0001 # ("bypass", (t.LVList(t.uint8_t), t.CharacterString), False), IAS_ACE_EMERGENCY = 0x0002 # ("emergency", (), False), @@ -47,12 +52,10 @@ SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" class IasAce(ZigbeeChannel): """IAS Ancillary Control Equipment channel.""" - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize IAS Ancillary Control Equipment channel.""" super().__init__(cluster, ch_pool) - self.command_map: dict[int, CALLABLE_T] = { + self.command_map: dict[int, Callable[..., Any]] = { IAS_ACE_ARM: self.arm, IAS_ACE_BYPASS: self._bypass, IAS_ACE_EMERGENCY: self._emergency, @@ -64,7 +67,7 @@ class IasAce(ZigbeeChannel): IAS_ACE_GET_BYPASSED_ZONE_LIST: self._get_bypassed_zone_list, IAS_ACE_GET_ZONE_STATUS: self._get_zone_status, } - self.arm_map: dict[AceCluster.ArmMode, CALLABLE_T] = { + self.arm_map: dict[AceCluster.ArmMode, Callable[..., Any]] = { AceCluster.ArmMode.Disarm: self._disarm, AceCluster.ArmMode.Arm_All_Zones: self._arm_away, AceCluster.ArmMode.Arm_Day_Home_Only: self._arm_day, @@ -89,7 +92,7 @@ class IasAce(ZigbeeChannel): ) self.command_map[command_id](*args) - def arm(self, arm_mode: int, code: str, zone_id: int): + def arm(self, arm_mode: int, code: str | None, zone_id: int) -> None: """Handle the IAS ACE arm command.""" mode = AceCluster.ArmMode(arm_mode) @@ -196,26 +199,17 @@ class IasAce(ZigbeeChannel): def _emergency(self) -> None: """Handle the IAS ACE emergency command.""" - self._set_alarm( - AceCluster.AlarmStatus.Emergency, - IAS_ACE_EMERGENCY, - ) + self._set_alarm(AceCluster.AlarmStatus.Emergency) def _fire(self) -> None: """Handle the IAS ACE fire command.""" - self._set_alarm( - AceCluster.AlarmStatus.Fire, - IAS_ACE_FIRE, - ) + self._set_alarm(AceCluster.AlarmStatus.Fire) def _panic(self) -> None: """Handle the IAS ACE panic command.""" - self._set_alarm( - AceCluster.AlarmStatus.Emergency_Panic, - IAS_ACE_PANIC, - ) + self._set_alarm(AceCluster.AlarmStatus.Emergency_Panic) - def _set_alarm(self, status: AceCluster.PanelStatus, event: str) -> None: + def _set_alarm(self, status: AceCluster.AlarmStatus) -> None: """Set the specified alarm status.""" self.alarm_status = status self.armed_state = AceCluster.PanelStatus.In_Alarm diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index b153372a322..66d3e3d6810 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -3,12 +3,22 @@ from __future__ import annotations import enum from functools import partialmethod +from typing import TYPE_CHECKING +import zigpy.zcl from zigpy.zcl.clusters import smartenergy -from .. import registries, typing as zha_typing -from ..const import REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP -from .base import ZigbeeChannel +from .. import registries +from ..const import ( + REPORT_CONFIG_ASAP, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_OP, + SIGNAL_ATTR_UPDATED, +) +from .base import AttrReportConfig, ZigbeeChannel + +if TYPE_CHECKING: + from . import ChannelPool @registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Calendar.cluster_id) @@ -56,9 +66,9 @@ class Metering(ZigbeeChannel): """Metering channel.""" REPORT_CONFIG = ( - {"attr": "instantaneous_demand", "config": REPORT_CONFIG_OP}, - {"attr": "current_summ_delivered", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "status", "config": REPORT_CONFIG_ASAP}, + AttrReportConfig(attr="instantaneous_demand", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="current_summ_delivered", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="status", config=REPORT_CONFIG_ASAP), ) ZCL_INIT_ATTRS = { "demand_formatting": True, @@ -109,13 +119,11 @@ class Metering(ZigbeeChannel): DEMAND = 0 SUMMATION = 1 - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) - self._format_spec = None - self._summa_format = None + self._format_spec: str | None = None + self._summa_format: str | None = None @property def divisor(self) -> int: @@ -123,7 +131,7 @@ class Metering(ZigbeeChannel): return self.cluster.get("divisor") or 1 @property - def device_type(self) -> int | None: + def device_type(self) -> str | int | None: """Return metering device type.""" dev_type = self.cluster.get("metering_device_type") if dev_type is None: @@ -146,7 +154,7 @@ class Metering(ZigbeeChannel): return self.DeviceStatusDefault(status) @property - def unit_of_measurement(self) -> str: + def unit_of_measurement(self) -> int: """Return unit of measurement.""" return self.cluster.get("unit_of_measure") @@ -163,6 +171,25 @@ class Metering(ZigbeeChannel): ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) + async def async_force_update(self) -> None: + """Retrieve latest state.""" + self.debug("async_force_update") + + attrs = [ + a["attr"] + for a in self.REPORT_CONFIG + if a["attr"] not in self.cluster.unsupported_attributes + ] + result = await self.get_attributes(attrs, from_cache=False, only_cache=False) + if result: + for attr, value in result.items(): + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.cluster.find_attribute(attr).id, + attr, + value, + ) + @staticmethod def get_formatting(formatting: int) -> str: """Return a formatting string, given the formatting value. @@ -183,18 +210,22 @@ class Metering(ZigbeeChannel): return f"{{:0{width}.{r_digits}f}}" - def _formatter_function(self, selector: FormatSelector, value: int) -> int | float: + def _formatter_function( + self, selector: FormatSelector, value: int + ) -> int | float | str: """Return formatted value for display.""" - value = value * self.multiplier / self.divisor + value_float = value * self.multiplier / self.divisor if self.unit_of_measurement == 0: # Zigbee spec power unit is kW, but we show the value in W - value_watt = value * 1000 + value_watt = value_float * 1000 if value_watt < 100: return round(value_watt, 1) return round(value_watt) if selector == self.FormatSelector.SUMMATION: - return self._summa_format.format(value).lstrip() - return self._format_spec.format(value).lstrip() + assert self._summa_format + return self._summa_format.format(value_float).lstrip() + assert self._format_spec + return self._format_spec.format(value_float).lstrip() demand_formatter = partialmethod(_formatter_function, FormatSelector.DEMAND) summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 1e249ebd52b..c2d9e926453 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -374,6 +374,7 @@ ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" ZHA_CHANNEL_READS_PER_REQ = 5 +ZHA_EVENT = "zha_event" ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" ZHA_GW_MSG_DEVICE_INFO = "device_info" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index e5b3403ba54..e83c0afbceb 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -5,12 +5,14 @@ import asyncio from collections.abc import Callable from datetime import timedelta from enum import Enum +from functools import cached_property import logging import random import time from typing import TYPE_CHECKING, Any from zigpy import types +import zigpy.device import zigpy.exceptions from zigpy.profiles import PROFILES import zigpy.quirks @@ -26,7 +28,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.event import async_track_time_interval -from . import channels, typing as zha_typing +from . import channels from .const import ( ATTR_ARGS, ATTR_ATTRIBUTE, @@ -77,6 +79,7 @@ from .helpers import LogMixin, async_get_zha_config_value if TYPE_CHECKING: from ..api import ClusterBinding + from .gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) _UPDATE_ALIVE_INTERVAL = (60, 90) @@ -98,8 +101,8 @@ class ZHADevice(LogMixin): def __init__( self, hass: HomeAssistant, - zigpy_device: zha_typing.ZigpyDeviceType, - zha_gateway: zha_typing.ZhaGatewayType, + zigpy_device: zigpy.device.Device, + zha_gateway: ZHAGateway, ) -> None: """Initialize the gateway.""" self.hass = hass @@ -149,17 +152,17 @@ class ZHADevice(LogMixin): self._ha_device_id = device_id @property - def device(self) -> zha_typing.ZigpyDeviceType: + def device(self) -> zigpy.device.Device: """Return underlying Zigpy device.""" return self._zigpy_device @property - def channels(self) -> zha_typing.ChannelsType: + def channels(self) -> channels.Channels: """Return ZHA channels.""" return self._channels @channels.setter - def channels(self, value: zha_typing.ChannelsType) -> None: + def channels(self, value: channels.Channels) -> None: """Channels setter.""" assert isinstance(value, channels.Channels) self._channels = value @@ -273,14 +276,23 @@ class ZHADevice(LogMixin): @property def skip_configuration(self) -> bool: """Return true if the device should not issue configuration related commands.""" - return self._zigpy_device.skip_configuration or self.is_coordinator + return self._zigpy_device.skip_configuration or bool(self.is_coordinator) @property def gateway(self): """Return the gateway for this device.""" return self._zha_gateway - @property + @cached_property + def device_automation_commands(self) -> dict[str, list[tuple[str, str]]]: + """Return the a lookup of commands to etype/sub_type.""" + commands: dict[str, list[tuple[str, str]]] = {} + for etype_subtype, trigger in self.device_automation_triggers.items(): + if command := trigger.get(ATTR_COMMAND): + commands.setdefault(command, []).append(etype_subtype) + return commands + + @cached_property def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: """Return the device automation triggers for this device.""" triggers = { @@ -321,8 +333,8 @@ class ZHADevice(LogMixin): def new( cls, hass: HomeAssistant, - zigpy_dev: zha_typing.ZigpyDeviceType, - gateway: zha_typing.ZhaGatewayType, + zigpy_dev: zigpy.device.Device, + gateway: ZHAGateway, restored: bool = False, ): """Create new device.""" @@ -807,7 +819,7 @@ class ZHADevice(LogMixin): fmt = f"{log_msg[1]} completed: %s" zdo.debug(fmt, *(log_msg[2] + (outcome,))) - def log(self, level: int, msg: str, *args: Any, **kwargs: dict) -> None: + def log(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: """Log a message.""" msg = f"[%s](%s): {msg}" args = (self.nwk, self.model) + args diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index cdad57834eb..6b690f4da08 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -6,7 +6,7 @@ from collections.abc import Callable import logging from typing import TYPE_CHECKING -from homeassistant import const as ha_const +from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -86,9 +86,7 @@ class ProbeEndpoint: unique_id = channel_pool.unique_id - component: str | None = self._device_configs.get(unique_id, {}).get( - ha_const.CONF_TYPE - ) + component: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) if component is None: ep_profile_id = channel_pool.endpoint.profile_id ep_device_type = channel_pool.endpoint.device_type @@ -136,7 +134,7 @@ class ProbeEndpoint: @staticmethod def probe_single_cluster( - component: str, + component: Platform | None, channel: base.ZigbeeChannel, ep_channels: ChannelPool, ) -> None: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 642a5b3ec55..099abfe5e88 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -8,7 +8,6 @@ from datetime import timedelta from enum import Enum import itertools import logging -import os import time import traceback from typing import TYPE_CHECKING, Any, NamedTuple, Union @@ -163,7 +162,7 @@ class ZHAGateway: app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( CONF_DATABASE, - os.path.join(self._hass.config.config_dir, 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] @@ -333,7 +332,7 @@ class ZHAGateway: def group_removed(self, zigpy_group: zigpy.group.Group) -> None: """Handle zigpy group removed event.""" self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED) - zha_group = self._groups.pop(zigpy_group.group_id, None) + zha_group = self._groups.pop(zigpy_group.group_id) zha_group.info("group_removed") self._cleanup_group_entity_registry_entries(zigpy_group) @@ -428,6 +427,7 @@ class ZHAGateway: ] # then we get all group entity entries tied to the coordinator + assert self.coordinator_zha_device all_group_entity_entries = er.async_entries_for_device( self.ha_entity_registry, self.coordinator_zha_device.device_id, @@ -672,7 +672,7 @@ class ZHAGateway: async def async_remove_zigpy_group(self, group_id: int) -> None: """Remove a Zigbee group from Zigpy.""" if not (group := self.groups.get(group_id)): - _LOGGER.debug("Group: %s:0x%04x could not be found", group.name, group_id) + _LOGGER.debug("Group: 0x%04x could not be found", group_id) return if group.members: tasks = [] @@ -687,7 +687,7 @@ class ZHAGateway: _LOGGER.debug("Shutting down ZHA ControllerApplication") for unsubscribe in self._unsubs: unsubscribe() - await self.application_controller.pre_shutdown() + await self.application_controller.shutdown() def handle_message( self, diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 1392041c4d4..7f1c9f09998 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -78,7 +78,7 @@ class ZHAGroupMember(LogMixin): return member_info @property - def associated_entities(self) -> list[GroupEntityReference]: + 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 zha_device_registry = self.device.gateway.device_registry @@ -150,9 +150,7 @@ class ZHAGroup(LogMixin): def members(self) -> list[ZHAGroupMember]: """Return the ZHA devices that are members of this group.""" return [ - ZHAGroupMember( - self, self._zha_gateway.devices.get(member_ieee), endpoint_id - ) + ZHAGroupMember(self, self._zha_gateway.devices[member_ieee], endpoint_id) for (member_ieee, endpoint_id) in self._zigpy_group.members.keys() if member_ieee in self._zha_gateway.devices ] diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 33d68822b9f..390ef290dc2 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -21,6 +21,7 @@ import voluptuous as vol import zigpy.exceptions import zigpy.types import zigpy.util +import zigpy.zcl import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry @@ -35,7 +36,6 @@ from .const import ( DATA_ZHA_GATEWAY, ) from .registries import BINDABLE_CLUSTERS -from .typing import ZhaDeviceType, ZigpyClusterType if TYPE_CHECKING: from .device import ZHADevice @@ -48,7 +48,7 @@ _T = TypeVar("_T") class BindingPair: """Information for binding.""" - source_cluster: ZigpyClusterType + source_cluster: zigpy.zcl.Cluster target_ieee: zigpy.types.EUI64 target_ep_id: int @@ -82,7 +82,7 @@ async def safe_read( async def get_matched_clusters( - source_zha_device: ZhaDeviceType, target_zha_device: ZhaDeviceType + source_zha_device: ZHADevice, target_zha_device: ZHADevice ) -> list[BindingPair]: """Get matched input/output cluster pairs for 2 devices.""" source_clusters = source_zha_device.async_get_std_clusters() @@ -169,6 +169,8 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: """Get a ZHA device for the given device registry id.""" device_registry = dr.async_get(hass) registry_device = device_registry.async_get(device_id) + if not registry_device: + raise ValueError(f"Device id `{device_id}` not found in registry.") zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee_address = list(list(registry_device.identifiers)[0])[1] ieee = zigpy.types.EUI64.convert(ieee_address) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index fb00e23ac6f..2480cf1cd43 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -4,7 +4,7 @@ from __future__ import annotations import collections from collections.abc import Callable import dataclasses -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar import attr from zigpy import zcl @@ -17,13 +17,18 @@ from homeassistant.const import Platform # importing channels updates registries from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .decorators import DictRegistry, SetRegistry -from .typing import CALLABLE_T, ChannelType if TYPE_CHECKING: + from ..entity import ZhaEntity, ZhaGroupEntity from .channels.base import ClientChannel, ZigbeeChannel + +_ZhaEntityT = TypeVar("_ZhaEntityT", bound=type["ZhaEntity"]) +_ZhaGroupEntityT = TypeVar("_ZhaGroupEntityT", bound=type["ZhaGroupEntity"]) + GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] +IKEA_AIR_PURIFIER_CLUSTER = 0xFC7D PHILLIPS_REMOTE_CLUSTER = 0xFC00 SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 @@ -106,7 +111,7 @@ CLIENT_CHANNELS_REGISTRY: DictRegistry[type[ClientChannel]] = DictRegistry() ZIGBEE_CHANNEL_REGISTRY: DictRegistry[type[ZigbeeChannel]] = DictRegistry() -def set_or_callable(value): +def set_or_callable(value) -> frozenset[str] | Callable: """Convert single str or None to a set. Pass through callables and sets.""" if value is None: return frozenset() @@ -117,22 +122,26 @@ def set_or_callable(value): return frozenset([str(value)]) +def _get_empty_frozenset() -> frozenset[str]: + return frozenset() + + @attr.s(frozen=True) class MatchRule: """Match a ZHA Entity to a channel name or generic id.""" - channel_names: set[str] | str = attr.ib( + channel_names: frozenset[str] = attr.ib( factory=frozenset, converter=set_or_callable ) - generic_ids: set[str] | str = attr.ib(factory=frozenset, converter=set_or_callable) - manufacturers: Callable | set[str] | str = attr.ib( - factory=frozenset, converter=set_or_callable + generic_ids: frozenset[str] = attr.ib(factory=frozenset, converter=set_or_callable) + manufacturers: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable ) - models: Callable | set[str] | str = attr.ib( - factory=frozenset, converter=set_or_callable + models: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable ) - aux_channels: Callable | set[str] | str = attr.ib( - factory=frozenset, converter=set_or_callable + aux_channels: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable ) @property @@ -158,10 +167,11 @@ class MatchRule: weight += 10 * len(self.channel_names) weight += 5 * len(self.generic_ids) - weight += 1 * len(self.aux_channels) + if isinstance(self.aux_channels, frozenset): + weight += 1 * len(self.aux_channels) return weight - def claim_channels(self, channel_pool: list[ChannelType]) -> list[ChannelType]: + def claim_channels(self, channel_pool: list[ZigbeeChannel]) -> list[ZigbeeChannel]: """Return a list of channels this rule matches + aux channels.""" claimed = [] if isinstance(self.channel_names, frozenset): @@ -215,8 +225,8 @@ class MatchRule: class EntityClassAndChannels: """Container for entity class and corresponding channels.""" - entity_class: CALLABLE_T - claimed_channel: list[ChannelType] + entity_class: type[ZhaEntity] + claimed_channel: list[ZigbeeChannel] class ZHAEntityRegistry: @@ -225,19 +235,19 @@ class ZHAEntityRegistry: def __init__(self): """Initialize Registry instance.""" self._strict_registry: dict[ - str, dict[MatchRule, CALLABLE_T] + str, dict[MatchRule, type[ZhaEntity]] ] = collections.defaultdict(dict) self._multi_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[CALLABLE_T]]] + str, 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[CALLABLE_T]]] + str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) - self._group_registry: dict[str, CALLABLE_T] = {} + self._group_registry: dict[str, type[ZhaGroupEntity]] = {} self.single_device_matches: dict[ Platform, dict[EUI64, list[str]] ] = collections.defaultdict(lambda: collections.defaultdict(list)) @@ -247,9 +257,9 @@ class ZHAEntityRegistry: component: str, manufacturer: str, model: str, - channels: list[ChannelType], - default: CALLABLE_T = None, - ) -> tuple[CALLABLE_T, list[ChannelType]]: + channels: list[ZigbeeChannel], + default: type[ZhaEntity] | None = None, + ) -> tuple[type[ZhaEntity] | None, list[ZigbeeChannel]]: """Match a ZHA Channels to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=lambda x: x.weight, reverse=True): @@ -263,11 +273,11 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ChannelType], - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ChannelType]]: + channels: list[ZigbeeChannel], + ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ChannelType] = set() + all_claimed: set[ZigbeeChannel] = 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) @@ -287,11 +297,11 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ChannelType], - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ChannelType]]: + channels: list[ZigbeeChannel], + ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ChannelType] = set() + all_claimed: set[ZigbeeChannel] = set() for ( component, stop_match_groups, @@ -310,26 +320,26 @@ class ZHAEntityRegistry: return result, list(all_claimed) - def get_group_entity(self, component: str) -> CALLABLE_T: + def get_group_entity(self, component: str) -> type[ZhaGroupEntity] | None: """Match a ZHA group to a ZHA Entity class.""" return self._group_registry.get(component) def strict_match( self, component: str, - channel_names: set[str] | str = None, - generic_ids: set[str] | str = None, - manufacturers: Callable | set[str] | str = None, - models: Callable | set[str] | str = None, - aux_channels: Callable | set[str] | str = None, - ) -> Callable[[CALLABLE_T], CALLABLE_T]: + channel_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_channels: Callable | set[str] | str | None = None, + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" rule = MatchRule( channel_names, generic_ids, manufacturers, models, aux_channels ) - def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: """Register a strict match rule. All non empty fields of a match rule must match. @@ -342,13 +352,13 @@ class ZHAEntityRegistry: def multipass_match( self, component: str, - channel_names: set[str] | str = None, - generic_ids: set[str] | str = None, - manufacturers: Callable | set[str] | str = None, - models: Callable | set[str] | str = None, - aux_channels: Callable | set[str] | str = None, + channel_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, - ) -> Callable[[CALLABLE_T], CALLABLE_T]: + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( @@ -359,7 +369,7 @@ class ZHAEntityRegistry: aux_channels, ) - def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: """Register a loose match rule. All non empty fields of a match rule must match. @@ -375,13 +385,13 @@ class ZHAEntityRegistry: def config_diagnostic_match( self, component: str, - channel_names: set[str] | str = None, - generic_ids: set[str] | str = None, - manufacturers: Callable | set[str] | str = None, - models: Callable | set[str] | str = None, - aux_channels: Callable | set[str] | str = None, + channel_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, - ) -> Callable[[CALLABLE_T], CALLABLE_T]: + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( @@ -392,7 +402,7 @@ class ZHAEntityRegistry: aux_channels, ) - def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: """Register a loose match rule. All non empty fields of a match rule must match. @@ -405,10 +415,12 @@ class ZHAEntityRegistry: return decorator - def group_match(self, component: str) -> Callable[[CALLABLE_T], CALLABLE_T]: + def group_match( + self, component: str + ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: """Decorate a group match rule.""" - def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_ent: _ZhaGroupEntityT) -> _ZhaGroupEntityT: """Register a group match rule.""" self._group_registry[component] = zha_ent return zha_ent @@ -426,9 +438,9 @@ class ZHAEntityRegistry: def clean_up(self) -> None: """Clean up post discovery.""" - self.single_device_matches: dict[ - Platform, dict[EUI64, list[str]] - ] = collections.defaultdict(lambda: collections.defaultdict(list)) + self.single_device_matches = collections.defaultdict( + lambda: collections.defaultdict(list) + ) ZHA_ENTITIES = ZHAEntityRegistry() diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py index 28983bdb427..e58dcd46dba 100644 --- a/homeassistant/components/zha/core/store.py +++ b/homeassistant/components/zha/core/store.py @@ -5,7 +5,7 @@ from collections import OrderedDict from collections.abc import MutableMapping import datetime import time -from typing import cast +from typing import TYPE_CHECKING, Any, cast import attr @@ -13,7 +13,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.loader import bind_hass -from .typing import ZhaDeviceType +if TYPE_CHECKING: + from .device import ZHADevice DATA_REGISTRY = "zha_storage" @@ -42,17 +43,18 @@ class ZhaStorage: self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) @callback - def async_create_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_create_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Create a new ZhaDeviceEntry.""" + ieee_str: str = str(device.ieee) device_entry: ZhaDeviceEntry = ZhaDeviceEntry( - name=device.name, ieee=str(device.ieee), last_seen=device.last_seen + name=device.name, ieee=ieee_str, last_seen=device.last_seen ) - self.devices[device_entry.ieee] = device_entry + self.devices[ieee_str] = device_entry self.async_schedule_save() return device_entry @callback - def async_get_or_create_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_get_or_create_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Create a new ZhaDeviceEntry.""" ieee_str: str = str(device.ieee) if ieee_str in self.devices: @@ -60,14 +62,14 @@ class ZhaStorage: return self.async_create_device(device) @callback - def async_create_or_update_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_create_or_update_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Create or update a ZhaDeviceEntry.""" if str(device.ieee) in self.devices: return self.async_update_device(device) return self.async_create_device(device) @callback - def async_delete_device(self, device: ZhaDeviceType) -> None: + def async_delete_device(self, device: ZHADevice) -> None: """Delete ZhaDeviceEntry.""" ieee_str: str = str(device.ieee) if ieee_str in self.devices: @@ -75,13 +77,13 @@ class ZhaStorage: self.async_schedule_save() @callback - def async_update_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_update_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Update name of ZhaDeviceEntry.""" ieee_str: str = str(device.ieee) old = self.devices[ieee_str] - if old is not None and device.last_seen is None: - return + if device.last_seen is None: + return old changes = {} changes["last_seen"] = device.last_seen @@ -92,7 +94,7 @@ class ZhaStorage: async def async_load(self) -> None: """Load the registry of zha device entries.""" - data = await self._store.async_load() + data = cast(dict[str, Any], await self._store.async_load()) devices: OrderedDict[str, ZhaDeviceEntry] = OrderedDict() diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py deleted file mode 100644 index 7e5cce8fec5..00000000000 --- a/homeassistant/components/zha/core/typing.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Typing helpers for ZHA component.""" -from collections.abc import Callable -from typing import TYPE_CHECKING, TypeVar - -import zigpy.device -import zigpy.endpoint -import zigpy.group -import zigpy.zcl -import zigpy.zdo - -# pylint: disable=invalid-name -CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) -ChannelType = "ZigbeeChannel" -ChannelsType = "Channels" -ChannelPoolType = "ChannelPool" -ClientChannelType = "ClientChannel" -ZDOChannelType = "ZDOChannel" -ZhaDeviceType = "ZHADevice" -ZhaEntityType = "ZHAEntity" -ZhaGatewayType = "ZHAGateway" -ZhaGroupType = "ZHAGroupType" -ZigpyClusterType = zigpy.zcl.Cluster -ZigpyDeviceType = zigpy.device.Device -ZigpyEndpointType = zigpy.endpoint.Endpoint -ZigpyGroupType = zigpy.group.Group -ZigpyZdoType = zigpy.zdo.ZDO - -if TYPE_CHECKING: - import homeassistant.components.zha.entity - - from . import channels, device, gateway, group - from .channels import base as base_channels - - ChannelType = base_channels.ZigbeeChannel - ChannelsType = channels.Channels - ChannelPoolType = channels.ChannelPool - ClientChannelType = base_channels.ClientChannel - ZDOChannelType = base_channels.ZDOChannel - ZhaDeviceType = device.ZHADevice - ZhaEntityType = homeassistant.components.zha.entity.ZhaEntity - ZhaGatewayType = gateway.ZHAGateway - ZhaGroupType = group.ZHAGroup diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0fdb4daeaa5..f6c67e6981d 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import functools import logging +from typing import TYPE_CHECKING, Any from zigpy.zcl.foundation import Status @@ -37,9 +38,12 @@ from .core.const import ( SIGNAL_SET_LEVEL, ) from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + _LOGGER = logging.getLogger(__name__) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.COVER) @@ -73,7 +77,7 @@ class ZhaCover(ZhaEntity, CoverEntity): self._cover_channel = self.cluster_channels.get(CHANNEL_COVER) self._current_position = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -88,24 +92,24 @@ class ZhaCover(ZhaEntity, CoverEntity): self._current_position = last_state.attributes["current_position"] @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self.current_cover_position is None: return None return self.current_cover_position == 0 @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._state == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._state == STATE_CLOSING @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of ZHA cover. None is unknown, 0 is closed, 100 is fully open. @@ -130,19 +134,19 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = state self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" res = await self._cover_channel.up_open() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_OPENING) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._cover_channel.down_close() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_CLOSING) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] res = await self._cover_channel.go_to_lift_percentage(100 - new_pos) @@ -151,14 +155,14 @@ class ZhaCover(ZhaEntity, CoverEntity): STATE_CLOSING if new_pos < self._current_position else STATE_OPENING ) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" res = await self._cover_channel.stop() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED self.async_write_ha_state() - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve the open/close state of the cover.""" await super().async_update() await self.async_get_state() @@ -191,19 +195,19 @@ class Shade(ZhaEntity, CoverEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Initialize the ZHA light.""" super().__init__(unique_id, zha_device, channels, **kwargs) self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] self._level_channel = self.cluster_channels[CHANNEL_LEVEL] - self._position = None - self._is_open = None + self._position: int | None = None + self._is_open: bool | None = None @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -217,7 +221,7 @@ class Shade(ZhaEntity, CoverEntity): return None return not self._is_open - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -247,7 +251,7 @@ class Shade(ZhaEntity, CoverEntity): self._position = int(value * 100 / 255) self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" res = await self._on_off_channel.on() if isinstance(res, Exception) or res[1] != Status.SUCCESS: @@ -257,7 +261,7 @@ class Shade(ZhaEntity, CoverEntity): self._is_open = True self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._on_off_channel.off() if isinstance(res, Exception) or res[1] != Status.SUCCESS: @@ -267,7 +271,7 @@ class Shade(ZhaEntity, CoverEntity): self._is_open = False self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] res = await self._level_channel.move_to_level_with_on_off( @@ -281,7 +285,7 @@ class Shade(ZhaEntity, CoverEntity): self._position = new_pos self.async_write_ha_state() - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" res = await self._level_channel.stop() if isinstance(res, Exception) or res[1] != Status.SUCCESS: @@ -297,7 +301,7 @@ class KeenVent(Shade): _attr_device_class = CoverDeviceClass.DAMPER - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" position = self._position or 100 tasks = [ diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 049ffbd40f3..3ee8694b09c 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -1,6 +1,8 @@ """Provides device actions for ZHA devices.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE @@ -82,9 +84,9 @@ async def async_get_actions( async def _execute_service_based_action( hass: HomeAssistant, - config: ACTION_SCHEMA, + config: dict[str, Any], variables: TemplateVarsType, - context: Context, + context: Context | None, ) -> None: action_type = config[CONF_TYPE] service_name = SERVICE_NAMES[action_type] diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index c08491ab782..cf4a830f4da 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -107,10 +107,10 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """ return self._battery_level - @property + @property # type: ignore[misc] def device_info( # pylint: disable=overridden-final-method self, - ) -> DeviceInfo | None: + ) -> DeviceInfo: """Return device info.""" # We opt ZHA device tracker back into overriding this method because # it doesn't track IP-based devices. @@ -118,7 +118,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): return super(ZhaEntity, self).device_info @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return unique ID.""" # Call Super because ScannerEntity overrode it. return super(ZhaEntity, self).unique_id diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 670b1cc1477..44682aaa559 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -1,4 +1,5 @@ """Provides device automations for ZHA devices that emit events.""" + import voluptuous as vol from homeassistant.components.automation import ( @@ -16,12 +17,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from . import DOMAIN +from .core.const import ZHA_EVENT from .core.helpers import async_get_zha_device CONF_SUBTYPE = "subtype" DEVICE = "device" DEVICE_IEEE = "device_ieee" -ZHA_EVENT = "zha_event" TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 5ae2ff23e96..697e7be336c 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -8,6 +8,8 @@ import bellows import pkg_resources import zigpy from zigpy.config import CONF_NWK_EXTENDED_PAN_ID +from zigpy.profiles import PROFILES +from zigpy.zcl import Cluster import zigpy_deconz import zigpy_xbee import zigpy_zigate @@ -15,11 +17,24 @@ import zigpy_znp from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.const import CONF_ID, CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .core.const import ATTR_IEEE, DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY +from .core.const import ( + ATTR_ATTRIBUTE_NAME, + ATTR_DEVICE_TYPE, + ATTR_IEEE, + ATTR_IN_CLUSTERS, + ATTR_OUT_CLUSTERS, + 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 @@ -27,11 +42,16 @@ from .core.helpers import async_get_zha_device KEYS_TO_REDACT = { ATTR_IEEE, CONF_UNIQUE_ID, + CONF_ALARM_MASTER_CODE, "network_key", CONF_NWK_EXTENDED_PAN_ID, "partner_ieee", } +ATTRIBUTES = "attributes" +CLUSTER_DETAILS = "cluster_details" +UNSUPPORTED_ATTRIBUTES = "unsupported_attributes" + def shallow_asdict(obj: Any) -> dict: """Return a shallow copy of a dataclass as a dict.""" @@ -77,4 +97,62 @@ async def async_get_device_diagnostics( ) -> dict: """Return diagnostics for a device.""" zha_device: ZHADevice = async_get_zha_device(hass, device.id) - return async_redact_data(zha_device.zha_device_info, KEYS_TO_REDACT) + device_info: dict[str, Any] = zha_device.zha_device_info + device_info[CLUSTER_DETAILS] = get_endpoint_cluster_attr_data(zha_device) + return async_redact_data(device_info, KEYS_TO_REDACT) + + +def get_endpoint_cluster_attr_data(zha_device: ZHADevice) -> dict: + """Return endpoint cluster attribute data.""" + cluster_details = {} + for ep_id, endpoint in zha_device.device.endpoints.items(): + if ep_id == 0: + continue + endpoint_key = ( + f"{PROFILES.get(endpoint.profile_id).DeviceType(endpoint.device_type).name}" + if PROFILES.get(endpoint.profile_id) is not None + and endpoint.device_type is not None + else UNKNOWN + ) + cluster_details[ep_id] = { + ATTR_DEVICE_TYPE: { + CONF_NAME: endpoint_key, + CONF_ID: endpoint.device_type, + }, + ATTR_PROFILE_ID: endpoint.profile_id, + ATTR_IN_CLUSTERS: { + f"0x{cluster_id:04x}": { + "endpoint_attribute": cluster.ep_attribute, + **get_cluster_attr_data(cluster), + } + for cluster_id, cluster in endpoint.in_clusters.items() + }, + ATTR_OUT_CLUSTERS: { + f"0x{cluster_id:04x}": { + "endpoint_attribute": cluster.ep_attribute, + **get_cluster_attr_data(cluster), + } + for cluster_id, cluster in endpoint.out_clusters.items() + }, + } + return cluster_details + + +def get_cluster_attr_data(cluster: Cluster) -> dict: + """Return cluster attribute data.""" + return { + ATTRIBUTES: { + f"0x{attr_id:04x}": { + ATTR_ATTRIBUTE_NAME: attr_def.name, + ATTR_VALUE: attr_value, + } + for attr_id, attr_def in cluster.attributes.items() + if (attr_value := cluster.get(attr_def.name)) is not None + }, + UNSUPPORTED_ATTRIBUTES: { + f"0x{cluster.find_attribute(u_attr).id:04x}": { + ATTR_ATTRIBUTE_NAME: cluster.find_attribute(u_attr).name + } + for u_attr in cluster.unsupported_attributes + }, + } diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index e9ea9ee871a..f70948eb04a 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import functools import logging from typing import TYPE_CHECKING, Any @@ -29,7 +30,6 @@ from .core.const import ( SIGNAL_REMOVE, ) from .core.helpers import LogMixin -from .core.typing import CALLABLE_T, ChannelType, ZhaDeviceType if TYPE_CHECKING: from .core.channels.base import ZigbeeChannel @@ -57,7 +57,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device = zha_device - self._unsubs: list[CALLABLE_T] = [] + self._unsubs: list[Callable[[], None]] = [] self.remove_future: asyncio.Future[Any] = asyncio.Future() @property @@ -127,13 +127,18 @@ class BaseZhaEntity(LogMixin, entity.Entity): @callback def async_accept_signal( - self, channel: ChannelType, signal: str, func: CALLABLE_T, signal_override=False + self, + channel: ZigbeeChannel | None, + signal: str, + func: Callable[..., Any], + signal_override=False, ): """Accept a signal from a channel.""" unsub = None if signal_override: unsub = async_dispatcher_connect(self.hass, signal, func) else: + assert channel unsub = async_dispatcher_connect( self.hass, f"{channel.unique_id}_{signal}", func ) @@ -181,8 +186,8 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -301,7 +306,7 @@ class ZhaGroupEntity(BaseZhaEntity): if self._change_listener_debouncer is None: self._change_listener_debouncer = Debouncer( self.hass, - self, + _LOGGER, cooldown=UPDATE_GROUP_FROM_CHILD_DELAY, immediate=False, function=functools.partial(self.async_update_ha_state, True), @@ -321,6 +326,7 @@ class ZhaGroupEntity(BaseZhaEntity): def async_state_changed_listener(self, event: Event): """Handle child updates.""" # Delay to ensure that we get updates from all members before updating the group + assert self._change_listener_debouncer self.hass.create_task(self._change_listener_debouncer.async_call()) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 1c2f52c6038..d947fca10ab 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod import functools import math +from typing import Any from zigpy.exceptions import ZigbeeException from zigpy.zcl.clusters import hvac @@ -50,6 +51,7 @@ DEFAULT_ON_PERCENTAGE = 50 STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.FAN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.FAN) async def async_setup_entry( @@ -87,17 +89,22 @@ class BaseFan(FanEntity): """Return the number of speeds the fan supports.""" return int_states_in_range(SPEED_RANGE) - async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the entity on.""" if percentage is None: percentage = DEFAULT_ON_PERCENTAGE await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.async_set_percentage(0) - async def async_set_percentage(self, percentage: int | None) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percenage of the fan.""" fan_mode = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) await self._async_set_fan_mode(fan_mode) @@ -128,7 +135,7 @@ class ZhaFan(BaseFan, ZhaEntity): super().__init__(unique_id, zha_device, channels, **kwargs) self._fan_channel = self.cluster_channels.get(CHANNEL_FAN) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -196,7 +203,7 @@ class FanGroup(BaseFan, ZhaGroupEntity): self.error("Could not set fan mode: %s", ex) self.async_set_state(0, "fan_mode", fan_mode) - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve on off state from the fan.""" all_states = [self.hass.states.get(x) for x in self._entity_ids] states: list[State] = list(filter(None, all_states)) @@ -222,3 +229,101 @@ class FanGroup(BaseFan, ZhaGroupEntity): """Run when about to be added to hass.""" await self.async_update() await super().async_added_to_hass() + + +IKEA_SPEED_RANGE = (1, 10) # off is not included +IKEA_PRESET_MODES_TO_NAME = { + 1: PRESET_MODE_AUTO, + 2: "Speed 1", + 3: "Speed 1.5", + 4: "Speed 2", + 5: "Speed 2.5", + 6: "Speed 3", + 7: "Speed 3.5", + 8: "Speed 4", + 9: "Speed 4.5", + 10: "Speed 5", +} +IKEA_NAME_TO_PRESET_MODE = {v: k for k, v in IKEA_PRESET_MODES_TO_NAME.items()} +IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class IkeaFan(BaseFan, ZhaEntity): + """Representation of a ZHA fan.""" + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._fan_channel = self.cluster_channels.get("ikea_airpurifier") + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @property + def preset_modes(self) -> list[str]: + """Return the available preset modes.""" + return IKEA_PRESET_MODES + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(IKEA_SPEED_RANGE) + + async def async_set_percentage(self, percentage: int | None) -> None: + """Set the speed percenage of the fan.""" + if percentage is None: + percentage = 0 + fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) + await self._async_set_fan_mode(fan_mode) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode for the fan.""" + if preset_mode not in self.preset_modes: + raise NotValidPresetModeError( + f"The preset_mode {preset_mode} is not a valid preset_mode: {self.preset_modes}" + ) + await self._async_set_fan_mode(IKEA_NAME_TO_PRESET_MODE[preset_mode]) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + if ( + self._fan_channel.fan_mode is None + or self._fan_channel.fan_mode > IKEA_SPEED_RANGE[1] + ): + return None + if self._fan_channel.fan_mode == 0: + return 0 + return ranged_value_to_percentage(IKEA_SPEED_RANGE, self._fan_channel.fan_mode) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) + + async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: + """Turn the entity on.""" + if percentage is None: + percentage = (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[ + PRESET_MODE_AUTO + ] + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + await self.async_set_percentage(0) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle state update from channel.""" + self.async_write_ha_state() + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + await self._fan_channel.async_set_speed(fan_mode) + self.async_set_state(0, "fan_mode", fan_mode) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2ec507109a2..309fdf2699b 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -3,12 +3,11 @@ from __future__ import annotations from collections import Counter from datetime import timedelta -import enum import functools import itertools import logging import random -from typing import Any +from typing import TYPE_CHECKING, Any, cast from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.lighting import Color @@ -17,16 +16,17 @@ from zigpy.zcl.foundation import Status from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - LightEntityFeature, + ATTR_SUPPORTED_COLOR_MODES, + ColorMode, + brightness_supported, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -62,9 +62,11 @@ from .core.const import ( ) from .core.helpers import LogMixin, async_get_zha_config_value from .core.registries import ZHA_ENTITIES -from .core.typing import ZhaDeviceType from .entity import ZhaEntity, ZhaGroupEntity +if TYPE_CHECKING: + from .core.device import ZHADevice + _LOGGER = logging.getLogger(__name__) CAPABILITIES_COLOR_LOOP = 0x4 @@ -72,6 +74,7 @@ CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 DEFAULT_TRANSITION = 1 +DEFAULT_MIN_BRIGHTNESS = 2 UPDATE_COLORLOOP_ACTION = 0x1 UPDATE_COLORLOOP_DIRECTION = 0x2 @@ -86,24 +89,14 @@ GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" +COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.HS} SUPPORT_GROUP_LIGHT = ( - SUPPORT_BRIGHTNESS - | SUPPORT_COLOR_TEMP - | LightEntityFeature.EFFECT - | LightEntityFeature.FLASH - | SUPPORT_COLOR - | LightEntityFeature.TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) -class LightColorMode(enum.IntEnum): - """ZCL light color mode enum.""" - - HS_COLOR = 0x00 - XY_COLOR = 0x01 - COLOR_TEMP = 0x02 - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -126,12 +119,14 @@ class BaseLight(LogMixin, light.LightEntity): """Operations common to all light entities.""" _FORCE_ON = False + _DEFAULT_COLOR_FROM_OFF_TRANSITION = 0 def __init__(self, *args, **kwargs): """Initialize the light.""" super().__init__(*args, **kwargs) self._available: bool = False self._brightness: int | None = None + self._off_with_transition: bool = False self._off_brightness: int | None = None self._hs_color: tuple[float, float] | None = None self._color_temp: int | None = None @@ -146,11 +141,15 @@ class BaseLight(LogMixin, light.LightEntity): self._color_channel = None self._identify_channel = None self._default_transition = None + self._color_mode = ColorMode.UNKNOWN # Set by sub classes @property def extra_state_attributes(self) -> dict[str, Any]: """Return state attributes.""" - attributes = {"off_brightness": self._off_brightness} + attributes = { + "off_with_transition": self._off_with_transition, + "off_brightness": self._off_brightness, + } return attributes @property @@ -160,6 +159,11 @@ class BaseLight(LogMixin, light.LightEntity): return False return self._state + @property + def color_mode(self): + """Return the color mode of this light.""" + return self._color_mode + @property def brightness(self): """Return the brightness of this light.""" @@ -226,17 +230,53 @@ class BaseLight(LogMixin, light.LightEntity): effect = kwargs.get(light.ATTR_EFFECT) flash = kwargs.get(light.ATTR_FLASH) - if brightness is None and self._off_brightness is not None: + # If the light is currently off but a turn_on call with a color/temperature is sent, + # the light needs to be turned on first at a low brightness level where the light is immediately transitioned + # to the correct color. Afterwards, the transition is only from the low brightness to the new brightness. + # Otherwise, the transition is from the color the light had before being turned on to the new color. + # This can look especially bad with transitions longer than a second. + color_provided_from_off = ( + not self._state + and brightness_supported(self._attr_supported_color_modes) + and (light.ATTR_COLOR_TEMP in kwargs or light.ATTR_HS_COLOR in kwargs) + ) + final_duration = duration + if color_provided_from_off: + # Set the duration for the color changing commands to 0. + duration = 0 + + if ( + brightness is None + and (self._off_with_transition or color_provided_from_off) + and self._off_brightness is not None + ): brightness = self._off_brightness + if brightness is not None: + level = min(254, brightness) + else: + level = self._brightness or 254 + t_log = {} + + if color_provided_from_off: + # If the light is currently off, we first need to turn it on at a low brightness level with no transition. + # After that, we set it to the desired color/temperature with no transition. + result = await self._level_channel.move_to_level_with_on_off( + DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_COLOR_FROM_OFF_TRANSITION + ) + t_log["move_to_level_with_on_off"] = result + if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + self.debug("turned on: %s", t_log) + return + # Currently only setting it to "on", as the correct level state will be set at the second move_to_level call + self._state = True + if ( - brightness is not None or transition - ) and self._supported_features & light.SUPPORT_BRIGHTNESS: - if brightness is not None: - level = min(254, brightness) - else: - level = self._brightness or 254 + (brightness is not None or transition) + and not color_provided_from_off + and brightness_supported(self._attr_supported_color_modes) + ): result = await self._level_channel.move_to_level_with_on_off( level, duration ) @@ -248,7 +288,11 @@ class BaseLight(LogMixin, light.LightEntity): if level: self._brightness = level - if brightness is None or (self._FORCE_ON and brightness): + if ( + brightness is None + and not color_provided_from_off + or (self._FORCE_ON and brightness) + ): # since some lights don't always turn on with move_to_level_with_on_off, # we should call the on command on the on_off cluster if brightness is not 0. result = await self._on_off_channel.on() @@ -257,23 +301,19 @@ class BaseLight(LogMixin, light.LightEntity): self.debug("turned on: %s", t_log) return self._state = True - if ( - light.ATTR_COLOR_TEMP in kwargs - and self.supported_features & light.SUPPORT_COLOR_TEMP - ): + + if light.ATTR_COLOR_TEMP in kwargs: temperature = kwargs[light.ATTR_COLOR_TEMP] result = await self._color_channel.move_to_color_temp(temperature, duration) t_log["move_to_color_temp"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return + self._color_mode = ColorMode.COLOR_TEMP self._color_temp = temperature self._hs_color = None - if ( - light.ATTR_HS_COLOR in kwargs - and self.supported_features & light.SUPPORT_COLOR - ): + if light.ATTR_HS_COLOR in kwargs: hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*hs_color) result = await self._color_channel.move_to_color( @@ -283,13 +323,22 @@ class BaseLight(LogMixin, light.LightEntity): if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return + self._color_mode = ColorMode.HS self._hs_color = hs_color self._color_temp = None - if ( - effect == light.EFFECT_COLORLOOP - and self.supported_features & light.LightEntityFeature.EFFECT - ): + if color_provided_from_off: + # The light is has the correct color, so we can now transition it to the correct brightness level. + result = await self._level_channel.move_to_level(level, final_duration) + t_log["move_to_level_if_color"] = result + if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + self.debug("turned on: %s", t_log) + return + self._state = bool(level) + if level: + self._brightness = level + + if effect == light.EFFECT_COLORLOOP: result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION | UPDATE_COLORLOOP_DIRECTION @@ -302,9 +351,7 @@ class BaseLight(LogMixin, light.LightEntity): t_log["color_loop_set"] = result self._effect = light.EFFECT_COLORLOOP elif ( - self._effect == light.EFFECT_COLORLOOP - and effect != light.EFFECT_COLORLOOP - and self.supported_features & light.LightEntityFeature.EFFECT + self._effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP ): result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION, @@ -316,15 +363,13 @@ class BaseLight(LogMixin, light.LightEntity): t_log["color_loop_set"] = result self._effect = None - if ( - flash is not None - and self._supported_features & light.LightEntityFeature.FLASH - ): + if flash is not None: result = await self._identify_channel.trigger_effect( FLASH_EFFECTS[flash], EFFECT_DEFAULT_VARIANT ) t_log["trigger_effect"] = result + self._off_with_transition = False self._off_brightness = None self.debug("turned on: %s", t_log) self.async_write_ha_state() @@ -332,7 +377,7 @@ class BaseLight(LogMixin, light.LightEntity): async def async_turn_off(self, **kwargs): """Turn the entity off.""" duration = kwargs.get(light.ATTR_TRANSITION) - supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS + supports_level = brightness_supported(self._attr_supported_color_modes) if duration and supports_level: result = await self._level_channel.move_to_level_with_on_off( @@ -345,8 +390,9 @@ class BaseLight(LogMixin, light.LightEntity): return self._state = False - if duration and supports_level: + if supports_level: # store current brightness so that the next turn_on uses it. + self._off_with_transition = bool(duration) self._off_brightness = self._brightness self.async_write_ha_state() @@ -356,12 +402,13 @@ class BaseLight(LogMixin, light.LightEntity): class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" + _attr_supported_color_modes: set[ColorMode] _REFRESH_INTERVAL = (45, 75) - def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): + def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs): """Initialize the ZHA light.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) + self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] self._state = bool(self._on_off_channel.on_off) self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) @@ -372,19 +419,20 @@ class Light(BaseLight, ZhaEntity): self._cancel_refresh_handle = None effect_list = [] + self._attr_supported_color_modes = {ColorMode.ONOFF} if self._level_channel: - self._supported_features |= light.SUPPORT_BRIGHTNESS + self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) self._supported_features |= light.LightEntityFeature.TRANSITION self._brightness = self._level_channel.current_level if self._color_channel: color_capabilities = self._color_channel.color_capabilities if color_capabilities & CAPABILITIES_COLOR_TEMP: - self._supported_features |= light.SUPPORT_COLOR_TEMP + self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) self._color_temp = self._color_channel.color_temperature if color_capabilities & CAPABILITIES_COLOR_XY: - self._supported_features |= light.SUPPORT_COLOR + self._attr_supported_color_modes.add(ColorMode.HS) curr_x = self._color_channel.current_x curr_y = self._color_channel.current_y if curr_x is not None and curr_y is not None: @@ -399,6 +447,17 @@ class Light(BaseLight, ZhaEntity): effect_list.append(light.EFFECT_COLORLOOP) if self._color_channel.color_loop_active == 1: self._effect = light.EFFECT_COLORLOOP + self._attr_supported_color_modes = filter_supported_color_modes( + self._attr_supported_color_modes + ) + if len(self._attr_supported_color_modes) == 1: + self._color_mode = next(iter(self._attr_supported_color_modes)) + else: # Light supports color_temp + hs, determine which mode the light is in + assert self._color_channel + if self._color_channel.color_mode == Color.ColorMode.Color_temperature: + self._color_mode = ColorMode.COLOR_TEMP + else: + self._color_mode = ColorMode.HS if self._identify_channel: self._supported_features |= light.LightEntityFeature.FLASH @@ -444,6 +503,7 @@ class Light(BaseLight, ZhaEntity): async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" + assert self._cancel_refresh_handle self._cancel_refresh_handle() await super().async_will_remove_from_hass() @@ -453,8 +513,12 @@ class Light(BaseLight, ZhaEntity): self._state = last_state.state == STATE_ON if "brightness" in last_state.attributes: self._brightness = last_state.attributes["brightness"] + if "off_with_transition" in last_state.attributes: + self._off_with_transition = last_state.attributes["off_with_transition"] if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] + if "color_mode" in last_state.attributes: + self._color_mode = ColorMode(last_state.attributes["color_mode"]) if "color_temp" in last_state.attributes: self._color_temp = last_state.attributes["color_temp"] if "hs_color" in last_state.attributes: @@ -493,12 +557,14 @@ class Light(BaseLight, ZhaEntity): ) if (color_mode := results.get("color_mode")) is not None: - if color_mode == LightColorMode.COLOR_TEMP: + if color_mode == Color.ColorMode.Color_temperature: + self._color_mode = ColorMode.COLOR_TEMP color_temp = results.get("color_temperature") if color_temp is not None and color_mode: self._color_temp = color_temp self._hs_color = None else: + self._color_mode = ColorMode.HS color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: @@ -552,6 +618,17 @@ class ForceOnLight(Light): _FORCE_ON = True +@STRICT_MATCH( + channel_names=CHANNEL_ON_OFF, + aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + manufacturers={"Sengled"}, +) +class SengledLight(Light): + """Representation of a Sengled light which does not react to move_to_color_temp with 0 as a transition.""" + + _DEFAULT_COLOR_FROM_OFF_TRANSITION = 1 + + @GROUP_MATCH() class LightGroup(BaseLight, ZhaGroupEntity): """Representation of a light group.""" @@ -573,6 +650,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): CONF_DEFAULT_LIGHT_TRANSITION, 0, ) + self._color_mode = None async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -633,6 +711,29 @@ class LightGroup(BaseLight, ZhaGroupEntity): effects_count = Counter(itertools.chain(all_effects)) self._effect = effects_count.most_common(1)[0][0] + self._attr_color_mode = None + all_color_modes = list( + helpers.find_state_attributes(on_states, ATTR_COLOR_MODE) + ) + if all_color_modes: + # Report the most common color mode, select brightness and onoff last + color_mode_count = Counter(itertools.chain(all_color_modes)) + if ColorMode.ONOFF in color_mode_count: + color_mode_count[ColorMode.ONOFF] = -1 + if ColorMode.BRIGHTNESS in color_mode_count: + color_mode_count[ColorMode.BRIGHTNESS] = 0 + self._attr_color_mode = color_mode_count.most_common(1)[0][0] + + self._attr_supported_color_modes = None + all_supported_color_modes = list( + helpers.find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + ) + if all_supported_color_modes: + # Merge all color modes. + self._attr_supported_color_modes = cast( + set[str], set().union(*all_supported_color_modes) + ) + self._supported_features = 0 for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 1ebb10cacb6..a2ec5e068cb 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -1,5 +1,6 @@ """Locks on Zigbee Home Automation networks.""" import functools +from typing import Any import voluptuous as vol from zigpy.zcl.foundation import Status @@ -10,6 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( @@ -52,7 +54,7 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_SET_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), @@ -61,7 +63,7 @@ async def async_setup_entry( "async_set_lock_user_code", ) - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_ENABLE_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), @@ -69,7 +71,7 @@ async def async_setup_entry( "async_enable_lock_user_code", ) - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_DISABLE_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), @@ -77,7 +79,7 @@ async def async_setup_entry( "async_disable_lock_user_code", ) - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_CLEAR_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), @@ -95,7 +97,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): super().__init__(unique_id, zha_device, channels, **kwargs) self._doorlock_channel = self.cluster_channels.get(CHANNEL_DOORLOCK) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -115,11 +117,11 @@ class ZhaDoorLock(ZhaEntity, LockEntity): return self._state == STATE_LOCKED @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, StateType]: """Return state attributes.""" return self.state_attributes - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" result = await self._doorlock_channel.lock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: @@ -127,7 +129,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): return self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" result = await self._doorlock_channel.unlock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: @@ -135,7 +137,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): return self.async_write_ha_state() - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve state from the lock.""" await super().async_update() await self.async_get_state() diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py new file mode 100644 index 00000000000..90d433be210 --- /dev/null +++ b/homeassistant/components/zha/logbook.py @@ -0,0 +1,85 @@ +"""Describe ZHA logbook events.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from homeassistant.components.logbook.const import ( + LOGBOOK_ENTRY_MESSAGE, + LOGBOOK_ENTRY_NAME, +) +from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .core.const import DOMAIN as ZHA_DOMAIN, ZHA_EVENT +from .core.helpers import async_get_zha_device + +if TYPE_CHECKING: + from .core.device import ZHADevice + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + device_registry = dr.async_get(hass) + + @callback + def async_describe_zha_event(event: Event) -> dict[str, str]: + """Describe zha logbook event.""" + device: dr.DeviceEntry | None = None + device_name: str = "Unknown device" + zha_device: ZHADevice | None = None + event_data: dict = event.data + event_type: str | None = None + event_subtype: str | None = None + + try: + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + if device: + device_name = device.name_by_user or device.name or "Unknown device" + zha_device = async_get_zha_device(hass, event.data[ATTR_DEVICE_ID]) + except (KeyError, AttributeError): + pass + + if ( + zha_device + and (command := event_data.get(ATTR_COMMAND)) + and (command_to_etype_subtype := zha_device.device_automation_commands) + and (etype_subtypes := command_to_etype_subtype.get(command)) + ): + all_triggers = zha_device.device_automation_triggers + for etype_subtype in etype_subtypes: + trigger = all_triggers[etype_subtype] + if not all( + event_data.get(key) == value for key, value in trigger.items() + ): + continue + event_type, event_subtype = etype_subtype + break + + if event_type is None: + event_type = event_data.get(ATTR_COMMAND, ZHA_EVENT) + + if event_subtype is not None and event_subtype != event_type: + event_type = f"{event_type} - {event_subtype}" + + if event_type is not None: + event_type = event_type.replace("_", " ").title() + if "event" in event_type.lower(): + message = f"{event_type} was fired" + else: + message = f"{event_type} event was fired" + + if params := event_data.get("params"): + message = f"{message} with parameters: {params}" + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: message, + } + + async_describe_event(ZHA_DOMAIN, ZHA_EVENT, async_describe_zha_event) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4f16b1c113e..78aea7c9fb6 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,15 +4,15 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.30.0", + "bellows==0.31.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.75", - "zigpy-deconz==0.16.0", - "zigpy==0.45.1", - "zigpy-xbee==0.14.0", - "zigpy-zigate==0.7.4", - "zigpy-znp==0.7.0" + "zha-quirks==0.0.77", + "zigpy-deconz==0.18.0", + "zigpy==0.47.2", + "zigpy-xbee==0.15.0", + "zigpy-zigate==0.9.0", + "zigpy-znp==0.8.0" ], "usb": [ { @@ -87,7 +87,7 @@ "name": "*zigate*" } ], - "after_dependencies": ["usb", "zeroconf"], + "after_dependencies": ["onboarding", "usb", "zeroconf"], "iot_class": "local_polling", "loggers": [ "aiosqlite", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 216b9974df6..e1268e29190 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -287,12 +287,12 @@ class ZhaNumber(ZhaEntity, NumberEntity): ) @property - def value(self): + def native_value(self): """Return the current value.""" return self._analog_output_channel.present_value @property - def min_value(self): + def native_min_value(self): """Return the minimum value.""" min_present_value = self._analog_output_channel.min_present_value if min_present_value is not None: @@ -300,7 +300,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): return 0 @property - def max_value(self): + def native_max_value(self): """Return the maximum value.""" max_present_value = self._analog_output_channel.max_present_value if max_present_value is not None: @@ -308,12 +308,12 @@ class ZhaNumber(ZhaEntity, NumberEntity): return 1023 @property - def step(self): + def native_step(self): """Return the value step.""" resolution = self._analog_output_channel.resolution if resolution is not None: return resolution - return super().step + return super().native_step @property def name(self): @@ -332,7 +332,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): return super().icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" engineering_units = self._analog_output_channel.engineering_units return UNITS.get(engineering_units) @@ -342,7 +342,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): """Handle value update from channel.""" self.async_write_ha_state() - async def async_set_value(self, value): + async def async_set_native_value(self, value): """Update the current value from HA.""" num_value = float(value) if await self._analog_output_channel.async_set_present_value(num_value): @@ -363,7 +363,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """Representation of a ZHA number configuration entity.""" _attr_entity_category = EntityCategory.CONFIG - _attr_step: float = 1.0 + _attr_native_step: float = 1.0 _zcl_attribute: str @classmethod @@ -404,11 +404,11 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): super().__init__(unique_id, zha_device, channels, **kwargs) @property - def value(self) -> float: + def native_value(self) -> float: """Return the current value.""" return self._channel.cluster.get(self._zcl_attribute) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" try: res = await self._channel.cluster.write_attributes( @@ -439,8 +439,8 @@ class AqaraMotionDetectionInterval( ): """Representation of a ZHA on off transition time configuration entity.""" - _attr_min_value: float = 2 - _attr_max_value: float = 65535 + _attr_native_min_value: float = 2 + _attr_native_max_value: float = 65535 _zcl_attribute: str = "detection_interval" @@ -450,8 +450,8 @@ class OnOffTransitionTimeConfigurationEntity( ): """Representation of a ZHA on off transition time configuration entity.""" - _attr_min_value: float = 0x0000 - _attr_max_value: float = 0xFFFF + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFF _zcl_attribute: str = "on_off_transition_time" @@ -459,8 +459,8 @@ class OnOffTransitionTimeConfigurationEntity( class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): """Representation of a ZHA on level configuration entity.""" - _attr_min_value: float = 0x00 - _attr_max_value: float = 0xFF + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFF _zcl_attribute: str = "on_level" @@ -470,8 +470,8 @@ class OnTransitionTimeConfigurationEntity( ): """Representation of a ZHA on transition time configuration entity.""" - _attr_min_value: float = 0x0000 - _attr_max_value: float = 0xFFFE + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "on_transition_time" @@ -481,8 +481,8 @@ class OffTransitionTimeConfigurationEntity( ): """Representation of a ZHA off transition time configuration entity.""" - _attr_min_value: float = 0x0000 - _attr_max_value: float = 0xFFFE + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "off_transition_time" @@ -492,8 +492,8 @@ class DefaultMoveRateConfigurationEntity( ): """Representation of a ZHA default move rate configuration entity.""" - _attr_min_value: float = 0x00 - _attr_max_value: float = 0xFE + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFE _zcl_attribute: str = "default_move_rate" @@ -503,8 +503,8 @@ class StartUpCurrentLevelConfigurationEntity( ): """Representation of a ZHA startup current level configuration entity.""" - _attr_min_value: float = 0x00 - _attr_max_value: float = 0xFF + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFF _zcl_attribute: str = "start_up_current_level" @@ -519,7 +519,21 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] - _attr_min_value: float = 0x00 - _attr_max_value: float = 0x257 + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0x257 _attr_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "timer_duration" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} +) +class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): + """Representation of a ZHA timer duration configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFFFFFFFF + _attr_unit_of_measurement: str | None = UNITS[72] + _zcl_attribute: str = "filter_life_time" diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 8714d804790..79349273e38 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -4,6 +4,7 @@ from __future__ import annotations from enum import Enum import functools import logging +from typing import TYPE_CHECKING from zigpy import types from zigpy.zcl.clusters.general import OnOff @@ -26,9 +27,13 @@ from .core.const import ( Strobe, ) from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + + CONFIG_DIAGNOSTIC_MATCH = functools.partial( ZHA_ENTITIES.config_diagnostic_match, Platform.SELECT ) @@ -59,19 +64,20 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA select entity.""" _attr_entity_category = EntityCategory.CONFIG - _enum: Enum = None + _attr_name: str + _enum: type[Enum] def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this select entity.""" self._attr_name = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] super().__init__(unique_id, zha_device, channels, **kwargs) @property @@ -82,7 +88,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): return None return option.name.replace("_", " ") - async def async_select_option(self, option: str | int) -> None: + async def async_select_option(self, option: str) -> None: """Change the selected option.""" self._channel.data_cache[self._attr_name] = self._enum[option.replace(" ", "_")] self.async_write_ha_state() @@ -111,7 +117,7 @@ class ZHADefaultToneSelectEntity( ): """Representation of a ZHA default siren tone select entity.""" - _enum: Enum = IasWd.Warning.WarningMode + _enum = IasWd.Warning.WarningMode @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -120,7 +126,7 @@ class ZHADefaultSirenLevelSelectEntity( ): """Representation of a ZHA default siren level select entity.""" - _enum: Enum = IasWd.Warning.SirenLevel + _enum = IasWd.Warning.SirenLevel @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -129,14 +135,14 @@ class ZHADefaultStrobeLevelSelectEntity( ): """Representation of a ZHA default siren strobe level select entity.""" - _enum: Enum = IasWd.StrobeLevel + _enum = IasWd.StrobeLevel @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__): """Representation of a ZHA default siren strobe select entity.""" - _enum: Enum = Strobe + _enum = Strobe class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @@ -144,14 +150,14 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): _select_attr: str _attr_entity_category = EntityCategory.CONFIG - _enum: Enum + _enum: type[Enum] @classmethod def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -175,13 +181,13 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this select entity.""" self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] super().__init__(unique_id, zha_device, channels, **kwargs) @property @@ -193,7 +199,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): option = self._enum(option) return option.name.replace("_", " ") - async def async_select_option(self, option: str | int) -> None: + async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._channel.cluster.write_attributes( {self._select_attr: self._enum[option.replace(" ", "_")]} @@ -208,7 +214,7 @@ class ZHAStartupOnOffSelectEntity( """Representation of a ZHA startup onoff select entity.""" _select_attr = "start_up_on_off" - _enum: Enum = OnOff.StartUpOnOff + _enum = OnOff.StartUpOnOff class AqaraMotionSensitivities(types.enum8): @@ -219,9 +225,42 @@ class AqaraMotionSensitivities(types.enum8): High = 0x03 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac02"}) +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.motion.ac01", "lumi.motion.ac02"} +) class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): """Representation of a ZHA on off transition time configuration entity.""" _select_attr = "motion_sensitivity" - _enum: Enum = AqaraMotionSensitivities + _enum = AqaraMotionSensitivities + + +class AqaraMonitoringModess(types.enum8): + """Aqara monitoring modes.""" + + Undirected = 0x00 + Left_Right = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): + """Representation of a ZHA monitoring mode configuration entity.""" + + _select_attr = "monitoring_mode" + _enum = AqaraMonitoringModess + + +class AqaraApproachDistances(types.enum8): + """Aqara approach distances.""" + + Far = 0x00 + Medium = 0x01 + Near = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): + """Representation of a ZHA approach distance configuration entity.""" + + _select_attr = "approach_distance" + _enum = AqaraApproachDistances diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 3e3017f6fa9..4a4700b3c4c 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import functools import numbers -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.climate.const import HVACAction from homeassistant.components.sensor import ( @@ -63,9 +63,12 @@ from .core.const import ( SIGNAL_ATTR_UPDATED, ) from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + PARALLEL_UPDATES = 5 BATTERY_SIZES = { @@ -115,26 +118,26 @@ class Sensor(ZhaEntity, SensorEntity): SENSOR_ATTR: int | str | None = None _decimals: int = 1 _divisor: int = 1 - _multiplier: int = 1 + _multiplier: int | float = 1 _unit: str | None = None def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this sensor.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] @classmethod def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -213,8 +216,8 @@ class Battery(Sensor): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -420,7 +423,10 @@ class Illuminance(Sensor): return round(pow(10, ((value - 1) / 10000)), 1) -@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +@MULTI_MATCH( + channel_names=CHANNEL_SMARTENERGY_METERING, + stop_on_match_group=CHANNEL_SMARTENERGY_METERING, +) class SmartEnergyMetering(Sensor): """Metering sensor.""" @@ -449,7 +455,7 @@ class SmartEnergyMetering(Sensor): return self._channel.demand_formatter(value) @property - def native_unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str | None: """Return Unit of measurement.""" return self.unit_of_measure_map.get(self._channel.unit_of_measurement) @@ -464,6 +470,26 @@ class SmartEnergyMetering(Sensor): return attrs +@MULTI_MATCH( + channel_names=CHANNEL_SMARTENERGY_METERING, + models={"TS011F"}, + stop_on_match_group=CHANNEL_SMARTENERGY_METERING, +) +class PolledSmartEnergyMetering(SmartEnergyMetering): + """Polled metering sensor.""" + + @property + def should_poll(self) -> bool: + """Poll the entity for current state.""" + return True + + async def async_update(self) -> None: + """Retrieve latest state.""" + if not self.available: + return + await self._channel.async_force_update() + + @MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): """Smart Energy Metering summation sensor.""" @@ -584,6 +610,17 @@ class PPBVOCLevel(Sensor): _unit = CONCENTRATION_PARTS_PER_BILLION +@MULTI_MATCH(channel_names="pm25") +class PM25(Sensor): + """Particulate Matter 2.5 microns or less sensor.""" + + SENSOR_ATTR = "measured_value" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _multiplier = 1 + _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + + @MULTI_MATCH(channel_names="formaldehyde_concentration") class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -603,8 +640,8 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -723,13 +760,14 @@ class RSSISensor(Sensor, id_suffix="rssi"): _attr_device_class: SensorDeviceClass = SensorDeviceClass.SIGNAL_STRENGTH _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False + unique_id_suffix: str @classmethod def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -770,3 +808,23 @@ class TimeLeft(Sensor, id_suffix="time_left"): _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" _unit = TIME_MINUTES + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): + """Sensor that displays device run time (in minutes).""" + + SENSOR_ATTR = "device_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _unit = TIME_MINUTES + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): + """Sensor that displays run time of the current filter (in minutes).""" + + SENSOR_ATTR = "filter_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _unit = TIME_MINUTES diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 38b58b8dc54..66cd2bf4002 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -1,8 +1,9 @@ """Support for ZHA sirens.""" from __future__ import annotations +from collections.abc import Callable import functools -from typing import Any +from typing import TYPE_CHECKING, Any, cast from zigpy.zcl.clusters.security import IasWd as WD @@ -38,9 +39,12 @@ from .core.const import ( Strobe, ) from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) DEFAULT_DURATION = 5 # seconds @@ -72,8 +76,8 @@ class ZHASiren(ZhaEntity, SirenEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this siren.""" @@ -93,9 +97,9 @@ class ZHASiren(ZhaEntity, SirenEntity): WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: IasWd = channels[0] + self._channel: IasWd = cast(IasWd, channels[0]) self._attr_is_on: bool = False - self._off_listener = None + self._off_listener: Callable[[], None] | None = None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index d9199ed77c8..3b044bb7646 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -62,10 +62,16 @@ async def async_setup_entry( class Switch(ZhaEntity, SwitchEntity): """ZHA switch.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + channels: list[ZigbeeChannel], + **kwargs: Any, + ) -> None: """Initialize the ZHA switch.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) + self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] @property def is_on(self) -> bool: @@ -74,14 +80,14 @@ class Switch(ZhaEntity, SwitchEntity): return False return self._on_off_channel.on_off - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" result = await self._on_off_channel.turn_on() if not result: return self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" result = await self._on_off_channel.turn_off() if not result: @@ -112,7 +118,12 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): """Representation of a switch group.""" def __init__( - self, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs + self, + entity_ids: list[str], + unique_id: str, + group_id: int, + zha_device: ZHADevice, + **kwargs: Any, ) -> None: """Initialize a switch group.""" super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) @@ -126,7 +137,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): """Return if the switch is on based on the statemachine.""" return bool(self._state) - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" result = await self._on_off_channel.on() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -134,7 +145,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" result = await self._on_off_channel.off() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -165,7 +176,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): unique_id: str, zha_device: ZHADevice, channels: list[ZigbeeChannel], - **kwargs, + **kwargs: Any, ) -> ZhaEntity | None: """Entity Factory. @@ -190,7 +201,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): unique_id: str, zha_device: ZHADevice, channels: list[ZigbeeChannel], - **kwargs, + **kwargs: Any, ) -> None: """Init this number configuration entity.""" self._channel: ZigbeeChannel = channels[0] @@ -215,7 +226,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): invert = bool(self._channel.cluster.get(self._zcl_inverter_attribute)) return (not val) if invert else val - async def async_turn_on_off(self, state) -> None: + async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" try: invert = bool(self._channel.cluster.get(self._zcl_inverter_attribute)) @@ -230,11 +241,11 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): ): self.async_write_ha_state() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self.async_turn_on_off(True) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.async_turn_on_off(False) @@ -263,8 +274,8 @@ class OnOffWindowDetectionFunctionConfigurationEntity( ): """Representation of a ZHA window detection configuration entity.""" - _zcl_attribute = "window_detection_function" - _zcl_inverter_attribute = "window_detection_function_inverter" + _zcl_attribute: str = "window_detection_function" + _zcl_inverter_attribute: str = "window_detection_function_inverter" @CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac02"}) @@ -273,4 +284,22 @@ class P1MotionTriggerIndicatorSwitch( ): """Representation of a ZHA motion triggering configuration entity.""" - _zcl_attribute = "trigger_indicator" + _zcl_attribute: str = "trigger_indicator" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} +) +class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): + """ZHA BinarySensor.""" + + _zcl_attribute: str = "child_lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} +) +class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): + """ZHA BinarySensor.""" + + _zcl_attribute: str = "disable_led" diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index 1044b726b27..f69e4fb36ff 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -11,7 +11,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Voulez-vous configurer {name} ?" + "description": "Voulez-vous configurer {name}\u00a0?" }, "pick_radio": { "data": { diff --git a/homeassistant/components/zha/translations/zh-Hans.json b/homeassistant/components/zha/translations/zh-Hans.json index 1dd51cd7e62..ab4b69efbb0 100644 --- a/homeassistant/components/zha/translations/zh-Hans.json +++ b/homeassistant/components/zha/translations/zh-Hans.json @@ -48,6 +48,13 @@ "close": "\u5173\u95ed", "dim_down": "\u8c03\u6697", "dim_up": "\u8c03\u4eae", + "face_1": "\u5e76\u4e14\u7b2c 1 \u9762\u6fc0\u6d3b", + "face_2": "\u5e76\u4e14\u7b2c 2 \u9762\u6fc0\u6d3b", + "face_3": "\u5e76\u4e14\u7b2c 3 \u9762\u6fc0\u6d3b", + "face_4": "\u5e76\u4e14\u7b2c 4 \u9762\u6fc0\u6d3b", + "face_5": "\u5e76\u4e14\u7b2c 5 \u9762\u6fc0\u6d3b", + "face_6": "\u5e76\u4e14\u7b2c 6 \u9762\u6fc0\u6d3b", + "face_any": "\u5e76\u4e14\u4efb\u610f\u6216\u6307\u5b9a\u9762\u6fc0\u6d3b", "left": "\u5de6", "open": "\u5f00\u542f", "right": "\u53f3", @@ -56,12 +63,12 @@ }, "trigger_type": { "device_dropped": "\u8bbe\u5907\u81ea\u7531\u843d\u4f53", - "device_flipped": "\u8bbe\u5907\u7ffb\u8f6c \"{subtype}\"", - "device_knocked": "\u8bbe\u5907\u8f7b\u6572 \"{subtype}\"", + "device_flipped": "\u8bbe\u5907\u7ffb\u8f6c{subtype}", + "device_knocked": "\u8bbe\u5907\u8f7b\u6572{subtype}", "device_offline": "\u8bbe\u5907\u79bb\u7ebf", - "device_rotated": "\u8bbe\u5907\u65cb\u8f6c \"{subtype}\"", + "device_rotated": "\u8bbe\u5907\u5411{subtype}\u65cb\u8f6c", "device_shaken": "\u8bbe\u5907\u6447\u4e00\u6447", - "device_slid": "\u8bbe\u5907\u5e73\u79fb \"{subtype}\"", + "device_slid": "\u8bbe\u5907\u5e73\u79fb{subtype}", "device_tilted": "\u8bbe\u5907\u503e\u659c", "remote_button_alt_double_press": "\"{subtype}\" \u53cc\u51fb(\u5907\u7528)", "remote_button_alt_long_press": "\"{subtype}\" \u957f\u6309(\u5907\u7528)", diff --git a/homeassistant/components/zoneminder/translations/sv.json b/homeassistant/components/zoneminder/translations/sv.json index 37fd73d32f0..4f0da20207f 100644 --- a/homeassistant/components/zoneminder/translations/sv.json +++ b/homeassistant/components/zoneminder/translations/sv.json @@ -9,6 +9,7 @@ "step": { "user": { "data": { + "username": "Anv\u00e4ndarnamn", "verify_ssl": "Verifiera SSL-certifikat" } } diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 4f5756361c8..fe616e8bdb9 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -104,8 +104,6 @@ from .services import ZWaveServices CONNECT_TIMEOUT = 10 DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_START_PLATFORM_TASK = "start_platform_task" -DATA_CONNECT_FAILED_LOGGED = "connect_failed_logged" -DATA_INVALID_SERVER_VERSION_LOGGED = "invalid_server_version_logged" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -170,28 +168,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_ensure_addon_running(hass, entry) client = ZwaveClient(entry.data[CONF_URL], async_get_clientsession(hass)) - entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {}) # connect and throw error if connection failed try: async with timeout(CONNECT_TIMEOUT): await client.connect() except InvalidServerVersion as err: - if not entry_hass_data.get(DATA_INVALID_SERVER_VERSION_LOGGED): - LOGGER.error("Invalid server version: %s", err) - entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = True if use_addon: async_ensure_addon_updated(hass) - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Invalid server version: {err}") from err except (asyncio.TimeoutError, BaseZwaveJSServerError) as err: - if not entry_hass_data.get(DATA_CONNECT_FAILED_LOGGED): - LOGGER.error("Failed to connect: %s", err) - entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = True - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Failed to connect: {err}") from err else: LOGGER.info("Connected to Zwave JS Server") - entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = False - entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = False dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) @@ -202,7 +191,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_register_api(hass) platform_task = hass.async_create_task(start_platforms(hass, entry, client)) - entry_hass_data[DATA_START_PLATFORM_TASK] = platform_task + hass.data[DOMAIN].setdefault(entry.entry_id, {})[ + DATA_START_PLATFORM_TASK + ] = platform_task return True @@ -635,9 +626,7 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: platform_task: asyncio.Task = data[DATA_START_PLATFORM_TASK] listen_task.cancel() platform_task.cancel() - platform_setup_tasks = ( - hass.data[DOMAIN].get(entry.entry_id, {}).get(DATA_PLATFORM_SETUP, {}).values() - ) + platform_setup_tasks = data.get(DATA_PLATFORM_SETUP, {}).values() for task in platform_setup_tasks: task.cancel() @@ -711,8 +700,7 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> try: addon_info = await addon_manager.async_get_addon_info() except AddonError as err: - LOGGER.error(err) - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(err) from err usb_path: str = entry.data[CONF_USB_PATH] # s0_legacy_key was saved as network_key before s2 was added. diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 27a0c065c0b..8b98c61c4b2 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import dataclasses from functools import partial, wraps -from typing import Any, Literal +from typing import Any, Literal, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -54,7 +54,6 @@ from homeassistant.components.websocket_api.const import ( ERR_UNKNOWN_ERROR, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers import config_validation as cv @@ -72,6 +71,7 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + get_device_id, update_data_collection_preference, ) @@ -378,6 +378,7 @@ def node_status(node: Node) -> dict[str, Any]: def async_register_api(hass: HomeAssistant) -> None: """Register all of our api endpoints.""" websocket_api.async_register_command(hass, websocket_network_status) + websocket_api.async_register_command(hass, websocket_subscribe_node_status) websocket_api.async_register_command(hass, websocket_node_status) websocket_api.async_register_command(hass, websocket_node_metadata) websocket_api.async_register_command(hass, websocket_node_comments) @@ -413,16 +414,22 @@ def async_register_api(hass: HomeAssistant) -> None: ) websocket_api.async_register_command(hass, websocket_data_collection_status) websocket_api.async_register_command(hass, websocket_abort_firmware_update) + websocket_api.async_register_command(hass, websocket_get_firmware_update_progress) websocket_api.async_register_command( hass, websocket_subscribe_firmware_update_status ) + websocket_api.async_register_command( + hass, websocket_get_firmware_update_capabilities + ) + websocket_api.async_register_command( + hass, websocket_get_any_firmware_update_progress + ) websocket_api.async_register_command(hass, websocket_check_for_config_updates) websocket_api.async_register_command(hass, websocket_install_config_update) websocket_api.async_register_command( hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) - websocket_api.async_register_command(hass, websocket_node_ready) hass.http.register_view(FirmwareUploadView()) @@ -497,25 +504,28 @@ async def websocket_network_status( @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/node_ready", + vol.Required(TYPE): "zwave_js/subscribe_node_status", vol.Required(DEVICE_ID): str, } ) @websocket_api.async_response @async_get_node -async def websocket_node_ready( +async def websocket_subscribe_node_status( hass: HomeAssistant, connection: ActiveConnection, msg: dict, node: Node, ) -> None: - """Subscribe to the node ready event of a Z-Wave JS node.""" + """Subscribe to node status update events of a Z-Wave JS node.""" @callback def forward_event(event: dict) -> None: """Forward the event.""" connection.send_message( - websocket_api.event_message(msg[ID], {"event": event["event"]}) + websocket_api.event_message( + msg[ID], + {"event": event["event"], "status": node.status, "ready": node.ready}, + ) ) @callback @@ -525,7 +535,10 @@ async def websocket_node_ready( unsub() connection.subscriptions[msg["id"]] = async_cleanup - msg[DATA_UNSUBSCRIBE] = unsubs = [node.on("ready", forward_event)] + msg[DATA_UNSUBSCRIBE] = unsubs = [ + node.on(evt, forward_event) + for evt in ("alive", "dead", "sleep", "wake up", "ready") + ] connection.send_result(msg[ID]) @@ -1102,8 +1115,7 @@ async def websocket_remove_node( @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/replace_failed_node", - vol.Required(ENTRY_ID): str, - vol.Required(NODE_ID): int, + vol.Required(DEVICE_ID): str, vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.All( vol.Coerce(int), vol.In( @@ -1126,18 +1138,16 @@ async def websocket_remove_node( ) @websocket_api.async_response @async_handle_failed_command -@async_get_entry +@async_get_node async def websocket_replace_failed_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict, - entry: ConfigEntry, - client: Client, - driver: Driver, + node: Node, ) -> None: """Replace a failed node with a new node.""" - controller = driver.controller - node_id = msg[NODE_ID] + assert node.client.driver + controller = node.client.driver.controller inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) force_security = msg.get(FORCE_SECURITY) provisioning = ( @@ -1251,7 +1261,7 @@ async def websocket_replace_failed_node( try: result = await controller.async_replace_failed_node( - controller.nodes[node_id], + node, INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value], force_security=force_security, provisioning=provisioning, @@ -1486,8 +1496,8 @@ async def websocket_refresh_node_info( node.on("interview failed", forward_event), ] - result = await node.async_refresh_info() - connection.send_result(msg[ID], result) + await node.async_refresh_info() + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -1861,6 +1871,26 @@ async def websocket_abort_firmware_update( connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_firmware_update_progress", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_get_firmware_update_progress( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Get whether firmware update is in progress.""" + connection.send_result(msg[ID], await node.async_get_firmware_update_progress()) + + def _get_firmware_update_progress_dict( progress: FirmwareUpdateProgress, ) -> dict[str, int]: @@ -1941,6 +1971,51 @@ async def websocket_subscribe_firmware_update_status( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_firmware_update_capabilities", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_get_firmware_update_capabilities( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Abort a firmware update.""" + capabilities = await node.async_get_firmware_update_capabilities() + connection.send_result(msg[ID], capabilities.to_dict()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_any_firmware_update_progress", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_get_any_firmware_update_progress( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Get whether any firmware updates are in progress.""" + connection.send_result( + msg[ID], await driver.controller.async_get_any_firmware_update_progress() + ) + + class FirmwareUploadView(HomeAssistantView): """View to upload firmware.""" @@ -1965,16 +2040,6 @@ class FirmwareUploadView(HomeAssistantView): raise web_exceptions.HTTPBadRequest raise web_exceptions.HTTPNotFound - if not self._dev_reg: - self._dev_reg = dr.async_get(hass) - device = self._dev_reg.async_get(device_id) - assert device - entry = next( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device.config_entries - ) - # Increase max payload request._client_max_size = 1024 * 1024 * 10 # pylint: disable=protected-access @@ -1983,18 +2048,23 @@ class FirmwareUploadView(HomeAssistantView): if "file" not in data or not isinstance(data["file"], web_request.FileField): raise web_exceptions.HTTPBadRequest + target = None + if "target" in data: + target = int(cast(str, data["target"])) + uploaded_file: web_request.FileField = data["file"] try: await begin_firmware_update( - entry.data[CONF_URL], + node.client.ws_server_url, node, uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), async_get_clientsession(hass), + target=target, ) except BaseZwaveJSServerError as err: - raise web_exceptions.HTTPBadRequest from err + raise web_exceptions.HTTPBadRequest(reason=str(err)) from err return self.json(None) @@ -2127,15 +2197,42 @@ async def websocket_subscribe_controller_statistics( ) -def _get_node_statistics_dict(statistics: NodeStatistics) -> dict[str, int]: +def _get_node_statistics_dict( + hass: HomeAssistant, statistics: NodeStatistics +) -> dict[str, Any]: """Get dictionary of node statistics.""" - return { + dev_reg = dr.async_get(hass) + + def _convert_node_to_device_id(node: Node) -> str: + """Convert a node to a device id.""" + driver = node.client.driver + assert driver + device = dev_reg.async_get_device({get_device_id(driver, node)}) + assert device + return device.id + + data: dict = { "commands_tx": statistics.commands_tx, "commands_rx": statistics.commands_rx, "commands_dropped_tx": statistics.commands_dropped_tx, "commands_dropped_rx": statistics.commands_dropped_rx, "timeout_response": statistics.timeout_response, + "rtt": statistics.rtt, + "rssi": statistics.rssi, + "lwr": statistics.lwr.as_dict() if statistics.lwr else None, + "nlwr": statistics.nlwr.as_dict() if statistics.nlwr else None, } + for key in ("lwr", "nlwr"): + if not data[key]: + continue + for key_2 in ("repeaters", "route_failed_between"): + if not data[key][key_2]: + continue + data[key][key_2] = [ + _convert_node_to_device_id(node) for node in data[key][key_2] + ] + + return data @websocket_api.require_admin @@ -2171,7 +2268,7 @@ async def websocket_subscribe_node_statistics( "event": event["event"], "source": "node", "node_id": node.node_id, - **_get_node_statistics_dict(statistics), + **_get_node_statistics_dict(hass, statistics), }, ) ) @@ -2187,7 +2284,7 @@ async def websocket_subscribe_node_statistics( "event": "statistics updated", "source": "node", "nodeId": node.node_id, - **_get_node_statistics_dict(node.statistics), + **_get_node_statistics_dict(hass, node.statistics), }, ) ) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index fb085cffe62..f6480689910 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -import logging from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass @@ -30,8 +29,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - NOTIFICATION_SMOKE_ALARM = "1" NOTIFICATION_CARBON_MONOOXIDE = "2" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index ee83db4578c..30364d127eb 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -1,7 +1,6 @@ """Support for Z-Wave cover devices.""" from __future__ import annotations -import logging from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient @@ -39,8 +38,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index a4271ac1c02..79dd1d27a4c 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,8 +1,6 @@ """Generic Z-Wave Entity Class.""" from __future__ import annotations -import logging - from zwave_js_server.const import NodeStatus from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value as ZwaveValue, get_value_id @@ -12,12 +10,10 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id -LOGGER = logging.getLogger(__name__) - EVENT_VALUE_UPDATED = "value updated" EVENT_VALUE_REMOVED = "value removed" EVENT_DEAD = "dead" diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index ae9df47b420..27f73353ca8 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -404,7 +404,7 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): return cast(str, self._fan_state.metadata.states[str(value)]) @property - def extra_state_attributes(self) -> dict[str, str] | None: + def extra_state_attributes(self) -> dict[str, str]: """Return the optional state attributes.""" attrs = {} diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 17293e85a21..4c8fe2a3986 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -1,7 +1,6 @@ """Support for Z-Wave lights.""" from __future__ import annotations -import logging from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient @@ -49,8 +48,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - MULTI_COLOR_MAP = { ColorComponent.WARM_WHITE: COLOR_SWITCH_COMBINED_WARM_WHITE, ColorComponent.COLD_WHITE: COLOR_SWITCH_COMBINED_COLD_WHITE, diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index ffe99373991..efeadb9b6b3 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -1,7 +1,6 @@ """Representation of Z-Wave locks.""" from __future__ import annotations -import logging from typing import Any import voluptuous as vol @@ -27,6 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DATA_CLIENT, DOMAIN, + LOGGER, SERVICE_CLEAR_LOCK_USERCODE, SERVICE_SET_LOCK_USERCODE, ) @@ -35,8 +35,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { CommandClass.DOOR_LOCK: { STATE_UNLOCKED: DoorLockMode.UNSECURED, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 1c7eabb4e86..d1097a6cd65 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.37.1"], + "requirements": ["zwave-js-server-python==0.39.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 737b872b7bc..1b17fa35024 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -71,34 +71,34 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): ) @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" if self.info.primary_value.metadata.min is None: return 0 return float(self.info.primary_value.metadata.min) @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" if self.info.primary_value.metadata.max is None: return 255 return float(self.info.primary_value.metadata.max) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value.""" if self.info.primary_value.value is None: return None return float(self.info.primary_value.value) @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" if self.info.primary_value.metadata.unit is None: return None return str(self.info.primary_value.metadata.unit) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" if (target_value := self._target_value) is None: raise HomeAssistantError("Missing target value on device.") @@ -121,19 +121,19 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): self.correction_factor = 1 # Entity class attributes - self._attr_min_value = 0 - self._attr_max_value = 1 - self._attr_step = 0.01 + self._attr_native_min_value = 0 + self._attr_native_max_value = 1 + self._attr_native_step = 0.01 self._attr_name = self.generate_name(include_value_name=True) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value.""" if self.info.primary_value.value is None: return None return float(self.info.primary_value.value) / self.correction_factor - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.info.node.async_set_value( self.info.primary_value, round(value * self.correction_factor) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 2b2e2a0de2b..22fbfdab728 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import cast import voluptuous as vol @@ -55,6 +54,7 @@ from .const import ( ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, + LOGGER, SERVICE_RESET_METER, ) from .discovery import ZwaveDiscoveryInfo @@ -67,8 +67,6 @@ from .helpers import get_device_id, get_valueless_base_unique_id PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - STATUS_ICON: dict[NodeStatus, str] = { NodeStatus.ALIVE: "mdi:heart-pulse", NodeStatus.ASLEEP: "mdi:sleep", diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 52b8f813326..154106d56f5 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -1,7 +1,6 @@ """Representation of Z-Wave switches.""" from __future__ import annotations -import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient @@ -23,8 +22,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/zwave_js/translations/bg.json b/homeassistant/components/zwave_js/translations/bg.json index 5d2486acbc1..dd6d70483d3 100644 --- a/homeassistant/components/zwave_js/translations/bg.json +++ b/homeassistant/components/zwave_js/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "flow_title": "{name}", diff --git a/homeassistant/components/zwave_js/translations/ja.json b/homeassistant/components/zwave_js/translations/ja.json index 902955c1b9e..f4c5a62b050 100644 --- a/homeassistant/components/zwave_js/translations/ja.json +++ b/homeassistant/components/zwave_js/translations/ja.json @@ -88,7 +88,7 @@ "event.value_notification.scene_activation": "{subtype} \u3067\u306e\u30b7\u30fc\u30f3\u306e\u30a2\u30af\u30c6\u30a3\u30d6\u5316", "state.node_status": "\u30ce\u30fc\u30c9\u30b9\u30c6\u30fc\u30bf\u30b9\u304c\u5909\u5316\u3057\u307e\u3057\u305f", "zwave_js.value_updated.config_parameter": "\u30b3\u30f3\u30d5\u30a3\u30b0\u30d1\u30e9\u30e1\u30fc\u30bf {subtype} \u306e\u5024\u306e\u5909\u66f4", - "zwave_js.value_updated.value": "Z-Wave JS\u5024\u306e\u5024\u306e\u5909\u66f4" + "zwave_js.value_updated.value": "Z-Wave JS\u5024\u306e\u5024\u3092\u5909\u66f4" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/sv.json b/homeassistant/components/zwave_js/translations/sv.json new file mode 100644 index 00000000000..907924e08ac --- /dev/null +++ b/homeassistant/components/zwave_js/translations/sv.json @@ -0,0 +1,12 @@ +{ + "device_automation": { + "condition_type": { + "value": "Nuvarande v\u00e4rde f\u00f6r ett Z-Wave v\u00e4rde" + } + }, + "options": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/zh-Hans.json b/homeassistant/components/zwave_js/translations/zh-Hans.json new file mode 100644 index 00000000000..815453d9b62 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "device_automation": { + "action_type": { + "ping": "Ping \u8bbe\u5907" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py index 69354daad43..7e0b4f02728 100644 --- a/homeassistant/components/zwave_me/button.py +++ b/homeassistant/components/zwave_me/button.py @@ -1,6 +1,4 @@ """Representation of a toggleButton.""" -from typing import Any - from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -41,6 +39,6 @@ async def async_setup_entry( class ZWaveMeButton(ZWaveMeEntity, ButtonEntity): """Representation of a ZWaveMe button.""" - def press(self, **kwargs: Any) -> None: + def press(self) -> None: """Turn the entity on.""" self.controller.zwave_api.send_command(self.device.id, "on") diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py index 4fee380ca48..9089e5514f5 100644 --- a/homeassistant/components/zwave_me/config_flow.py +++ b/homeassistant/components/zwave_me/config_flow.py @@ -1,4 +1,5 @@ """Config flow to configure ZWaveMe integration.""" +from __future__ import annotations import logging @@ -6,7 +7,9 @@ from url_normalize import url_normalize import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.data_entry_flow import FlowResult from . import helpers from .const import DOMAIN @@ -17,13 +20,15 @@ _LOGGER = logging.getLogger(__name__) class ZWaveMeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """ZWaveMe integration config flow.""" - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" - self.url = None - self.token = None - self.uuid = None + self.url: str | None = None + self.token: str | None = None + self.uuid: str | None = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle a flow initialized by the user or started with zeroconf.""" errors = {} placeholders = { @@ -55,6 +60,7 @@ class ZWaveMeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not self.url.startswith(("ws://", "wss://")): self.url = f"ws://{self.url}" self.url = url_normalize(self.url, default_scheme="ws") + assert self.url if self.uuid is None: self.uuid = await helpers.get_uuid(self.url, self.token) if self.uuid is not None: @@ -76,7 +82,9 @@ class ZWaveMeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> FlowResult: """ Handle a discovered Z-Wave accessory - get url to pass into user step. diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py index 7857306ef1f..5e2fdba8608 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -53,11 +53,11 @@ class ZWaveMeCover(ZWaveMeEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close cover.""" self.controller.zwave_api.send_command(self.device.id, "exact?level=0") - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open cover.""" self.controller.zwave_api.send_command(self.device.id, "exact?level=99") diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index 45d238a6541..c332fb305c5 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -66,7 +66,7 @@ class ZWaveMeFan(ZWaveMeEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" self.set_percentage(percentage if percentage is not None else 99) diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py index efa7ceb8603..2fa82514626 100644 --- a/homeassistant/components/zwave_me/number.py +++ b/homeassistant/components/zwave_me/number.py @@ -40,13 +40,13 @@ class ZWaveMeNumber(ZWaveMeEntity, NumberEntity): """Representation of a ZWaveMe Multilevel Switch.""" @property - def value(self): + def native_value(self): """Return the unit of measurement.""" if self.device.level == 99: # Scale max value return 100 return self.device.level - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Update the current value.""" self.controller.zwave_api.send_command( self.device.id, f"exact?level={str(round(value))}" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 0ac02adb8d0..c832bab7eb4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -73,6 +73,7 @@ PATH_CONFIG = ".config_entries.json" SAVE_DELAY = 1 _T = TypeVar("_T", bound="ConfigEntryState") +_R = TypeVar("_R") class ConfigEntryState(Enum): @@ -90,6 +91,8 @@ class ConfigEntryState(Enum): """The config entry has not been loaded""" FAILED_UNLOAD = "failed_unload", False """An error occurred when trying to unload the entry""" + SETUP_IN_PROGRESS = "setup_in_progress", False + """The config entry is setting up.""" _recoverable: bool @@ -102,13 +105,17 @@ class ConfigEntryState(Enum): @property def recoverable(self) -> bool: - """Get if the state is recoverable.""" + """Get if the state is recoverable. + + If the entry state is recoverable, unloads + and reloads are allowed. + """ return self._recoverable DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" -DISCOVERY_SOURCES = ( +DISCOVERY_SOURCES = { SOURCE_DHCP, SOURCE_DISCOVERY, SOURCE_HOMEKIT, @@ -119,7 +126,7 @@ DISCOVERY_SOURCES = ( SOURCE_UNIGNORE, SOURCE_USB, SOURCE_ZEROCONF, -) +} RECONFIGURE_NOTIFICATION_ID = "config_entry_reconfigure" @@ -187,6 +194,7 @@ class ConfigEntry: "_async_cancel_retry_setup", "_on_unload", "reload_lock", + "_pending_tasks", ) def __init__( @@ -279,6 +287,8 @@ class ConfigEntry: # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() + self._pending_tasks: list[asyncio.Future[Any]] = [] + async def async_setup( self, hass: HomeAssistant, @@ -294,6 +304,10 @@ class ConfigEntry: if integration is None: integration = await loader.async_get_integration(hass, self.domain) + # Only store setup result as state if it was not forwarded. + if self.domain == integration.domain: + self.state = ConfigEntryState.SETUP_IN_PROGRESS + self.supports_unload = await support_entry_unload(hass, self.domain) self.supports_remove_device = await support_remove_from_device( hass, self.domain @@ -356,7 +370,7 @@ class ConfigEntry: self.domain, auth_message, ) - self._async_process_on_unload() + await self._async_process_on_unload() self.async_start_reauth(hass) result = False except ConfigEntryNotReady as ex: @@ -396,7 +410,7 @@ class ConfigEntry: EVENT_HOMEASSISTANT_STARTED, setup_again ) - self._async_process_on_unload() + await self._async_process_on_unload() return except Exception: # pylint: disable=broad-except _LOGGER.exception( @@ -484,7 +498,7 @@ class ConfigEntry: self.state = ConfigEntryState.NOT_LOADED self.reason = None - self._async_process_on_unload() + await self._async_process_on_unload() # https://github.com/python/mypy/issues/11839 return result # type: ignore[no-any-return] @@ -609,13 +623,18 @@ class ConfigEntry: self._on_unload = [] self._on_unload.append(func) - @callback - def _async_process_on_unload(self) -> None: - """Process the on_unload callbacks.""" + async def _async_process_on_unload(self) -> None: + """Process the on_unload callbacks and wait for pending tasks.""" if self._on_unload is not None: while self._on_unload: self._on_unload.pop()() + while self._pending_tasks: + pending = [task for task in self._pending_tasks if not task.done()] + self._pending_tasks.clear() + if pending: + await asyncio.gather(*pending) + @callback def async_start_reauth(self, hass: HomeAssistant) -> None: """Start a reauth flow.""" @@ -638,6 +657,22 @@ class ConfigEntry: ) ) + @callback + def async_create_task( + self, hass: HomeAssistant, target: Coroutine[Any, Any, _R] + ) -> asyncio.Task[_R]: + """Create a task from within the eventloop. + + This method must be run in the event loop. + + target: target to call. + """ + task = hass.async_create_task(target) + + self._pending_tasks.append(task) + + return task + current_entry: ContextVar[ConfigEntry | None] = ContextVar( "current_entry", default=None @@ -676,7 +711,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): if not self._async_has_other_discovery_flows(flow.flow_id): persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result # Check if config entry exists with unique ID. Unload it. @@ -1242,24 +1277,36 @@ class ConfigFlow(data_entry_flow.FlowHandler): return for entry in self._async_current_entries(include_ignore=True): - if entry.unique_id == self.unique_id: - if updates is not None: - changed = self.hass.config_entries.async_update_entry( - entry, data={**entry.data, **updates} - ) - if ( - changed - and reload_on_update - and entry.state - in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) - ): - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - # Allow ignored entries to be configured on manual user step - if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: - continue - raise data_entry_flow.AbortFlow("already_configured") + if entry.unique_id != self.unique_id: + continue + should_reload = False + if ( + updates is not None + and self.hass.config_entries.async_update_entry( + entry, data={**entry.data, **updates} + ) + and reload_on_update + and entry.state + in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) + ): + # Existing config entry present, and the + # entry data just changed + should_reload = True + elif ( + self.source in DISCOVERY_SOURCES + and entry.state is ConfigEntryState.SETUP_RETRY + ): + # Existing config entry present in retry state, and we + # just discovered the unique id so we know its online + should_reload = True + # Allow ignored entries to be configured on manual user step + if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: + continue + if should_reload: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + raise data_entry_flow.AbortFlow("already_configured") async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True @@ -1391,7 +1438,10 @@ class ConfigFlow(data_entry_flow.FlowHandler): @callback def async_abort( - self, *, reason: str, description_placeholders: dict | None = None + self, + *, + reason: str, + description_placeholders: Mapping[str, str] | None = None, ) -> data_entry_flow.FlowResult: """Abort the config flow.""" # Remove reauth notification if no reauth flows are in progress @@ -1465,7 +1515,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): title: str, data: Mapping[str, Any], description: str | None = None, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None, ) -> data_entry_flow.FlowResult: """Finish config flow and create a config entry.""" @@ -1513,7 +1563,7 @@ class OptionsFlowManager(data_entry_flow.FlowManager): """ flow = cast(OptionsFlow, flow) - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result entry = self.hass.config_entries.async_get_entry(flow.handler) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3bcf2a603b4..c2ee7de691f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,8 +6,8 @@ from typing import Final from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 -MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "7" +MINOR_VERSION: Final = 7 +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, 9, 0) @@ -608,10 +608,12 @@ CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" # Speed units SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +SPEED_FEET_PER_SECOND: Final = "ft/s" SPEED_INCHES_PER_DAY: Final = "in/d" SPEED_METERS_PER_SECOND: Final = "m/s" SPEED_INCHES_PER_HOUR: Final = "in/h" SPEED_KILOMETERS_PER_HOUR: Final = "km/h" +SPEED_KNOTS: Final = "kn" SPEED_MILES_PER_HOUR: Final = "mph" # Signal_strength units diff --git a/homeassistant/core.py b/homeassistant/core.py index d7cae4e411e..b568ee72689 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -37,6 +37,7 @@ from typing import ( ) from urllib.parse import urlparse +from typing_extensions import ParamSpec import voluptuous as vol import yarl @@ -98,6 +99,7 @@ block_async_io.enable() _T = TypeVar("_T") _R = TypeVar("_R") _R_co = TypeVar("_R_co", covariant=True) +_P = ParamSpec("_P") # Internal; not helpers.typing.UNDEFINED due to circular dependency _UNDEF: dict[Any, Any] = {} _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -182,7 +184,7 @@ class HassJobType(enum.Enum): Executor = 3 -class HassJob(Generic[_R_co]): +class HassJob(Generic[_P, _R_co]): """Represent a job to be run later. We check the callable type in advance @@ -192,7 +194,7 @@ class HassJob(Generic[_R_co]): __slots__ = ("job_type", "target") - def __init__(self, target: Callable[..., _R_co]) -> None: + def __init__(self, target: Callable[_P, _R_co]) -> None: """Create a job object.""" self.target = target self.job_type = _get_hassjob_callable_job_type(target) @@ -416,20 +418,20 @@ class HomeAssistant: @overload @callback def async_add_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R]], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any ) -> asyncio.Future[_R] | None: ... @overload @callback def async_add_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: ... @callback def async_add_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: """Add a HassJob from within the event loop. @@ -512,20 +514,20 @@ class HomeAssistant: @overload @callback def async_run_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R]], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any ) -> asyncio.Future[_R] | None: ... @overload @callback def async_run_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: ... @callback def async_run_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: """Run a HassJob from within the event loop. @@ -814,7 +816,7 @@ class Event: class _FilterableJob(NamedTuple): """Event listener job to be executed with optional filter.""" - job: HassJob[None | Awaitable[None]] + job: HassJob[[Event], None | Awaitable[None]] event_filter: Callable[[Event], bool] | None run_immediately: bool @@ -1108,6 +1110,13 @@ class State: self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None + def __hash__(self) -> int: + """Make the state hashable. + + State objects are effectively immutable. + """ + return hash((id(self), self.last_updated)) + @property def name(self) -> str: """Name of this state.""" diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 628a89dd89b..23b35138df7 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -10,10 +10,27 @@ from typing import Any, TypedDict import voluptuous as vol +from .backports.enum import StrEnum from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .helpers.frame import report from .util import uuid as uuid_util + +class FlowResultType(StrEnum): + """Result type for a data entry flow.""" + + FORM = "form" + CREATE_ENTRY = "create_entry" + ABORT = "abort" + EXTERNAL_STEP = "external" + EXTERNAL_STEP_DONE = "external_done" + SHOW_PROGRESS = "progress" + SHOW_PROGRESS_DONE = "progress_done" + MENU = "menu" + + +# RESULT_TYPE_* is deprecated, to be removed in 2022.9 RESULT_TYPE_FORM = "form" RESULT_TYPE_CREATE_ENTRY = "create_entry" RESULT_TYPE_ABORT = "abort" @@ -52,7 +69,7 @@ class AbortFlow(FlowError): """Exception to indicate a flow needs to be aborted.""" def __init__( - self, reason: str, description_placeholders: dict | None = None + self, reason: str, description_placeholders: Mapping[str, str] | None = None ) -> None: """Initialize an abort flow exception.""" super().__init__(f"Flow aborted: {reason}") @@ -64,7 +81,7 @@ class FlowResult(TypedDict, total=False): """Typed result dict.""" version: int - type: str + type: FlowResultType flow_id: str handler: str title: str @@ -75,7 +92,7 @@ class FlowResult(TypedDict, total=False): required: bool errors: dict[str, str] | None description: str | None - description_placeholders: dict[str, Any] | None + description_placeholders: Mapping[str, str | None] | None progress_action: str url: str reason: str @@ -92,12 +109,12 @@ def _async_flow_handler_to_flow_result( ) -> list[FlowResult]: """Convert a list of FlowHandler to a partial FlowResult that can be serialized.""" return [ - { - "flow_id": flow.flow_id, - "handler": flow.handler, - "context": flow.context, - "step_id": flow.cur_step["step_id"] if flow.cur_step else None, - } + FlowResult( + flow_id=flow.flow_id, + handler=flow.handler, + context=flow.context, + step_id=flow.cur_step["step_id"] if flow.cur_step else None, + ) for flow in flows if include_uninitialized or flow.cur_step is not None ] @@ -207,7 +224,7 @@ class FlowManager(abc.ABC): self._initialize_tasks[handler].remove(task) self._initializing[handler].remove(init_done) - if result["type"] != RESULT_TYPE_ABORT: + if result["type"] != FlowResultType.ABORT: await self.async_post_init(flow, result) return result @@ -252,7 +269,7 @@ class FlowManager(abc.ABC): user_input = cur_step["data_schema"](user_input) # Handle a menu navigation choice - if cur_step["type"] == RESULT_TYPE_MENU and user_input: + if cur_step["type"] == FlowResultType.MENU and user_input: result = await self._async_handle_step( flow, user_input["next_step_id"], None ) @@ -261,18 +278,25 @@ class FlowManager(abc.ABC): flow, cur_step["step_id"], user_input ) - if cur_step["type"] in (RESULT_TYPE_EXTERNAL_STEP, RESULT_TYPE_SHOW_PROGRESS): - if cur_step["type"] == RESULT_TYPE_EXTERNAL_STEP and result["type"] not in ( - RESULT_TYPE_EXTERNAL_STEP, - RESULT_TYPE_EXTERNAL_STEP_DONE, + if cur_step["type"] in ( + FlowResultType.EXTERNAL_STEP, + FlowResultType.SHOW_PROGRESS, + ): + if cur_step["type"] == FlowResultType.EXTERNAL_STEP and result[ + "type" + ] not in ( + FlowResultType.EXTERNAL_STEP, + FlowResultType.EXTERNAL_STEP_DONE, ): raise ValueError( "External step can only transition to " "external step or external step done." ) - if cur_step["type"] == RESULT_TYPE_SHOW_PROGRESS and result["type"] not in ( - RESULT_TYPE_SHOW_PROGRESS, - RESULT_TYPE_SHOW_PROGRESS_DONE, + if cur_step["type"] == FlowResultType.SHOW_PROGRESS and result[ + "type" + ] not in ( + FlowResultType.SHOW_PROGRESS, + FlowResultType.SHOW_PROGRESS_DONE, ): raise ValueError( "Show progress can only transition to show progress or show progress done." @@ -282,7 +306,7 @@ class FlowManager(abc.ABC): # the frontend. if ( cur_step["step_id"] != result.get("step_id") - or result["type"] == RESULT_TYPE_SHOW_PROGRESS + or result["type"] == FlowResultType.SHOW_PROGRESS ): # Tell frontend to reload the flow state. self.hass.bus.async_fire( @@ -345,25 +369,21 @@ class FlowManager(abc.ABC): if step_done: step_done.set_result(None) - if result["type"] not in ( - RESULT_TYPE_FORM, - RESULT_TYPE_EXTERNAL_STEP, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_ABORT, - RESULT_TYPE_EXTERNAL_STEP_DONE, - RESULT_TYPE_SHOW_PROGRESS, - RESULT_TYPE_SHOW_PROGRESS_DONE, - RESULT_TYPE_MENU, - ): - raise ValueError(f"Handler returned incorrect type: {result['type']}") + if not isinstance(result["type"], FlowResultType): + result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] + report( + "does not use FlowResultType enum for data entry flow result type. " + "This is deprecated and will stop working in Home Assistant 2022.9", + error_if_core=False, + ) if result["type"] in ( - RESULT_TYPE_FORM, - RESULT_TYPE_EXTERNAL_STEP, - RESULT_TYPE_EXTERNAL_STEP_DONE, - RESULT_TYPE_SHOW_PROGRESS, - RESULT_TYPE_SHOW_PROGRESS_DONE, - RESULT_TYPE_MENU, + FlowResultType.FORM, + FlowResultType.EXTERNAL_STEP, + FlowResultType.EXTERNAL_STEP_DONE, + FlowResultType.SHOW_PROGRESS, + FlowResultType.SHOW_PROGRESS_DONE, + FlowResultType.MENU, ): flow.cur_step = result return result @@ -372,7 +392,7 @@ class FlowManager(abc.ABC): result = await self.async_finish_flow(flow, result.copy()) # _async_finish_flow may change result type, check it again - if result["type"] == RESULT_TYPE_FORM: + if result["type"] == FlowResultType.FORM: flow.cur_step = result return result @@ -422,20 +442,20 @@ class FlowHandler: step_id: str, data_schema: vol.Schema | None = None, errors: dict[str, str] | None = None, - description_placeholders: dict[str, Any] | None = None, + description_placeholders: Mapping[str, str | None] | None = None, last_step: bool | None = None, ) -> FlowResult: """Return the definition of a form to gather user input.""" - return { - "type": RESULT_TYPE_FORM, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "data_schema": data_schema, - "errors": errors, - "description_placeholders": description_placeholders, - "last_step": last_step, # Display next or submit button in frontend - } + return FlowResult( + type=FlowResultType.FORM, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + data_schema=data_schema, + errors=errors, + description_placeholders=description_placeholders, + last_step=last_step, # Display next or submit button in frontend + ) @callback def async_create_entry( @@ -444,23 +464,26 @@ class FlowHandler: title: str, data: Mapping[str, Any], description: str | None = None, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Finish config flow and create a config entry.""" - return { - "version": self.VERSION, - "type": RESULT_TYPE_CREATE_ENTRY, - "flow_id": self.flow_id, - "handler": self.handler, - "title": title, - "data": data, - "description": description, - "description_placeholders": description_placeholders, - } + return FlowResult( + version=self.VERSION, + type=FlowResultType.CREATE_ENTRY, + flow_id=self.flow_id, + handler=self.handler, + title=title, + data=data, + description=description, + description_placeholders=description_placeholders, + ) @callback def async_abort( - self, *, reason: str, description_placeholders: dict | None = None + self, + *, + reason: str, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Abort the config flow.""" return _create_abort_data( @@ -469,27 +492,31 @@ class FlowHandler: @callback def async_external_step( - self, *, step_id: str, url: str, description_placeholders: dict | None = None + self, + *, + step_id: str, + url: str, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Return the definition of an external step for the user to take.""" - return { - "type": RESULT_TYPE_EXTERNAL_STEP, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "url": url, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.EXTERNAL_STEP, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + url=url, + description_placeholders=description_placeholders, + ) @callback def async_external_step_done(self, *, next_step_id: str) -> FlowResult: """Return the definition of an external step for the user to take.""" - return { - "type": RESULT_TYPE_EXTERNAL_STEP_DONE, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": next_step_id, - } + return FlowResult( + type=FlowResultType.EXTERNAL_STEP_DONE, + flow_id=self.flow_id, + handler=self.handler, + step_id=next_step_id, + ) @callback def async_show_progress( @@ -497,27 +524,27 @@ class FlowHandler: *, step_id: str, progress_action: str, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Show a progress message to the user, without user input allowed.""" - return { - "type": RESULT_TYPE_SHOW_PROGRESS, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "progress_action": progress_action, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.SHOW_PROGRESS, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + progress_action=progress_action, + description_placeholders=description_placeholders, + ) @callback def async_show_progress_done(self, *, next_step_id: str) -> FlowResult: """Mark the progress done.""" - return { - "type": RESULT_TYPE_SHOW_PROGRESS_DONE, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": next_step_id, - } + return FlowResult( + type=FlowResultType.SHOW_PROGRESS_DONE, + flow_id=self.flow_id, + handler=self.handler, + step_id=next_step_id, + ) @callback def async_show_menu( @@ -525,21 +552,21 @@ class FlowHandler: *, step_id: str, menu_options: list[str] | dict[str, str], - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Show a navigation menu to the user. Options dict maps step_id => i18n label """ - return { - "type": RESULT_TYPE_MENU, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "data_schema": vol.Schema({"next_step_id": vol.In(menu_options)}), - "menu_options": menu_options, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.MENU, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + data_schema=vol.Schema({"next_step_id": vol.In(menu_options)}), + menu_options=menu_options, + description_placeholders=description_placeholders, + ) @callback @@ -547,13 +574,13 @@ def _create_abort_data( flow_id: str, handler: str, reason: str, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Return the definition of an external step for the user to take.""" - return { - "type": RESULT_TYPE_ABORT, - "flow_id": flow_id, - "handler": handler, - "reason": reason, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.ABORT, + flow_id=flow_id, + handler=handler, + reason=reason, + description_placeholders=description_placeholders, + ) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6d40b3fdef7..ba9762f58c0 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -11,6 +11,7 @@ APPLICATION_CREDENTIALS = [ "home_connect", "lyric", "neato", + "nest", "netatmo", "senz", "spotify", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e9ba5971e07..d7ed9159d7f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -87,6 +87,7 @@ FLOWS = { "ecobee", "econet", "efergy", + "eight_sleep", "elgato", "elkm1", "elmax", @@ -161,7 +162,6 @@ FLOWS = { "hvv_departures", "hyperion", "ialarm", - "ialarm_xr", "iaqualink", "icloud", "ifttt", @@ -189,6 +189,7 @@ FLOWS = { "kulersky", "launch_library", "laundrify", + "lg_soundbar", "life360", "lifx", "litejet", @@ -280,6 +281,7 @@ FLOWS = { "qnap_qsw", "rachio", "radio_browser", + "radiotherm", "rainforest_eagle", "rainmachine", "rdw", @@ -309,7 +311,9 @@ FLOWS = { "shelly", "shopping_list", "sia", + "simplepush", "simplisafe", + "skybell", "slack", "sleepiq", "slimproto", @@ -324,7 +328,6 @@ FLOWS = { "solarlog", "solax", "soma", - "somfy", "somfy_mylink", "sonarr", "songpal", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 015d70e2939..e9cf6ca4c06 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -23,10 +23,12 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'broadlink', 'macaddress': '24DFA7*'}, {'domain': 'broadlink', 'macaddress': 'A043B0*'}, {'domain': 'broadlink', 'macaddress': 'B4430D*'}, + {'domain': 'broadlink', 'macaddress': 'C8F742*'}, {'domain': 'elkm1', 'registered_devices': True}, {'domain': 'elkm1', 'macaddress': '00409D*'}, {'domain': 'emonitor', 'hostname': 'emonitor*', 'macaddress': '0090C2*'}, {'domain': 'emonitor', 'registered_devices': True}, + {'domain': 'esphome', 'registered_devices': True}, {'domain': 'flume', 'hostname': 'flume-gw-*'}, {'domain': 'flux_led', 'registered_devices': True}, {'domain': 'flux_led', 'hostname': '[ba][lk]*', 'macaddress': '18B905*'}, @@ -74,9 +76,12 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'oncue', 'hostname': 'kohlergen*', 'macaddress': '00146F*'}, {'domain': 'overkiz', 'hostname': 'gateway*', 'macaddress': 'F8811A*'}, {'domain': 'powerwall', 'hostname': '1118431-*'}, + {'domain': 'qnap_qsw', 'macaddress': '245EBE*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '009D6B*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': 'F0038C*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '74C63B*'}, + {'domain': 'radiotherm', 'hostname': 'thermostat*', 'macaddress': '5CDAD4*'}, + {'domain': 'radiotherm', 'registered_devices': True}, {'domain': 'rainforest_eagle', 'macaddress': 'D8D5B9*'}, {'domain': 'ring', 'hostname': 'ring*', 'macaddress': '0CAE7D*'}, {'domain': 'roomba', 'hostname': 'irobot-*', 'macaddress': '501479*'}, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 415e2746c6d..faca1c17854 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -233,10 +233,6 @@ ZEROCONF = { { "domain": "overkiz", "name": "gateway*" - }, - { - "domain": "somfy", - "name": "gateway*" } ], "_leap._tcp.local.": [ @@ -370,7 +366,7 @@ ZEROCONF = { "name": "smappee50*" } ], - "_system-bridge._udp.local.": [ + "_system-bridge._tcp.local.": [ { "domain": "system_bridge" } @@ -410,6 +406,7 @@ ZEROCONF = { HOMEKIT = { "3810X": "roku", + "3820X": "roku", "4660X": "roku", "7820X": "roku", "819LMB": "myq", @@ -419,6 +416,7 @@ HOMEKIT = { "C105X": "roku", "C135X": "roku", "EB-*": "ecobee", + "HHKBridge*": "hive", "Healty Home Coach": "netatmo", "Iota": "abode", "LIFX A19": "lifx", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index eaabb002b0a..2e56698db41 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -14,6 +14,7 @@ from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout import async_timeout +import orjson from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ @@ -97,6 +98,7 @@ def _async_create_clientsession( """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( connector=_async_get_connector(hass, verify_ssl), + json_serialize=lambda x: orjson.dumps(x).decode("utf-8"), **kwargs, ) # Prevent packages accidentally overriding our default headers diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index fddc5c82725..3617c0b1f29 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -6,7 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, cast from homeassistant import config_entries -from homeassistant.components import dhcp, mqtt, ssdp, zeroconf +from homeassistant.components import dhcp, onboarding, ssdp, zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -15,6 +15,9 @@ from .typing import UNDEFINED, DiscoveryInfoType, UndefinedType if TYPE_CHECKING: import asyncio + from homeassistant.components import mqtt + + _R = TypeVar("_R", bound="Awaitable[bool] | bool") DiscoveryFunctionType = Callable[[HomeAssistant], _R] @@ -52,7 +55,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow, Generic[_R]): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm setup.""" - if user_input is None: + if user_input is None and onboarding.async_is_onboarded(self.hass): self._set_confirm_only() return self.async_show_form(step_id="confirm") diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 9322d6e9dc1..0dc3415f7a9 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -233,6 +233,11 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """Extra data that needs to be appended to the authorize url.""" return {} + async def async_generate_authorize_url(self) -> str: + """Generate a url for the user to authorize.""" + url = await self.flow_impl.async_generate_authorize_url(self.flow_id) + return str(URL(url).update_query(self.extra_authorize_data)) + async def async_step_pick_implementation( self, user_input: dict | None = None ) -> FlowResult: @@ -278,7 +283,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): try: async with async_timeout.timeout(10): - url = await self.flow_impl.async_generate_authorize_url(self.flow_id) + url = await self.async_generate_authorize_url() except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") except NoURLAvailableError: @@ -289,8 +294,6 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): }, ) - url = str(URL(url).update_query(self.extra_authorize_data)) - return self.async_external_step(step_id="auth", url=url) async def async_step_creation( diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f459d96040b..2ed4bc7abab 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -758,7 +758,7 @@ def ensure_list_csv(value: Any) -> list: class multi_select: """Multi select validator returning list of selected values.""" - def __init__(self, options: dict) -> None: + def __init__(self, options: dict | list) -> None: """Initialize multi select.""" self.options = options diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 2126e048fc5..444876a7674 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -26,7 +26,7 @@ class _BaseFlowManagerView(HomeAssistantView): self, result: data_entry_flow.FlowResult ) -> data_entry_flow.FlowResult: """Convert result to JSON.""" - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: data = result.copy() data.pop("result") data.pop("data") diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 1c03e2334fe..f00f7d85e76 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -32,22 +32,13 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import ( - CALLBACK_TYPE, - Context, - Event, - HomeAssistant, - callback, - split_entity_id, ) +from homeassistant.core import CALLBACK_TYPE, Context, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify -from . import entity_registry as er +from . import device_registry as dr, entity_registry as er from .device_registry import DeviceEntryType from .entity_platform import EntityPlatform from .event import async_track_entity_registry_updated_event @@ -230,6 +221,7 @@ class EntityDescription: entity_registry_visible_default: bool = True force_update: bool = False icon: str | None = None + has_entity_name: bool = False name: str | None = None unit_of_measurement: str | None = None @@ -259,9 +251,6 @@ class Entity(ABC): # If we reported this entity is updated while disabled _disabled_reported = False - # If we reported this entity is relying on deprecated temperature conversion - _temperature_reported = False - # Protect for multiple updates _update_staged = False @@ -289,6 +278,7 @@ class Entity(ABC): _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_category: EntityCategory | None + _attr_has_entity_name: bool _attr_entity_picture: str | None = None _attr_entity_registry_enabled_default: bool _attr_entity_registry_visible_default: bool @@ -315,6 +305,15 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id + @property + def has_entity_name(self) -> bool: + """Return if the name of the entity is describing only the entity itself.""" + if hasattr(self, "_attr_has_entity_name"): + return self._attr_has_entity_name + if hasattr(self, "entity_description"): + return self.entity_description.has_entity_name + return False + @property def name(self) -> str | None: """Return the name of the entity.""" @@ -595,7 +594,26 @@ class Entity(ABC): if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - if (name := (entry and entry.name) or self.name) is not None: + def friendly_name() -> str | None: + """Return the friendly name. + + If has_entity_name is False, this returns self.name + If has_entity_name is True, this returns device.name + self.name + """ + if not self.has_entity_name or not self.registry_entry: + return self.name + + device_registry = dr.async_get(self.hass) + if not (device_id := self.registry_entry.device_id) or not ( + device_entry := device_registry.async_get(device_id) + ): + return self.name + + if not self.name: + return device_entry.name_by_user or device_entry.name + return f"{device_entry.name_by_user or device_entry.name} {self.name}" + + if (name := (entry and entry.name) or friendly_name()) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: @@ -618,58 +636,6 @@ class Entity(ABC): if DATA_CUSTOMIZE in self.hass.data: attr.update(self.hass.data[DATA_CUSTOMIZE].get(self.entity_id)) - def _convert_temperature(state: str, attr: dict[str, Any]) -> str: - # Convert temperature if we detect one - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.sensor import SensorEntity - - unit_of_measure = attr.get(ATTR_UNIT_OF_MEASUREMENT) - units = self.hass.config.units - if unit_of_measure == units.temperature_unit or unit_of_measure not in ( - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - ): - return state - - domain = split_entity_id(self.entity_id)[0] - if domain != "sensor": - if not self._temperature_reported: - self._temperature_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Entity %s (%s) relies on automatic temperature conversion, this will " - "be unsupported in Home Assistant Core 2022.7. Please %s", - self.entity_id, - type(self), - report_issue, - ) - elif not isinstance(self, SensorEntity): - if not self._temperature_reported: - self._temperature_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Temperature sensor %s (%s) does not inherit SensorEntity, " - "this will be unsupported in Home Assistant Core 2022.7." - "Please %s", - self.entity_id, - type(self), - report_issue, - ) - else: - return state - - try: - prec = len(state) - state.index(".") - 1 if "." in state else 0 - temp = units.temperature(float(state), unit_of_measure) - state = str(round(temp) if prec == 0 else round(temp, prec)) - attr[ATTR_UNIT_OF_MEASUREMENT] = units.temperature_unit - except ValueError: - # Could not convert state to float - pass - return state - - state = _convert_temperature(state, attr) - if ( self._context_set is not None and dt_util.utcnow() - self._context_set > self.context_recent_time diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ecf2125962a..6253b939bed 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -214,8 +214,9 @@ class EntityPlatform: def async_create_setup_task() -> Coroutine: """Get task to set up platform.""" config_entries.current_entry.set(config_entry) + return platform.async_setup_entry( # type: ignore[no-any-return,union-attr] - self.hass, config_entry, self._async_schedule_add_entities + self.hass, config_entry, self._async_schedule_add_entities_for_entry ) return await self._async_setup_platform(async_create_setup_task) @@ -334,6 +335,20 @@ class EntityPlatform: if not self._setup_complete: self._tasks.append(task) + @callback + def _async_schedule_add_entities_for_entry( + self, new_entities: Iterable[Entity], update_before_add: bool = False + ) -> None: + """Schedule adding entities for a single platform async and track the task.""" + assert self.config_entry + task = self.config_entry.async_create_task( + self.hass, + self.async_add_entities(new_entities, update_before_add=update_before_add), + ) + + if not self._setup_complete: + self._tasks.append(task) + def add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -440,15 +455,6 @@ class EntityPlatform: # Get entity_id from unique ID registration if entity.unique_id is not None: - if entity.entity_id is not None: - requested_entity_id = entity.entity_id - suggested_object_id = split_entity_id(entity.entity_id)[1] - else: - suggested_object_id = entity.name # type: ignore[unreachable] - - if self.entity_namespace is not None: - suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" - if self.config_entry is not None: config_entry_id: str | None = self.config_entry.entry_id else: @@ -503,6 +509,22 @@ class EntityPlatform: except RequiredParameterMissing: pass + if entity.entity_id is not None: + requested_entity_id = entity.entity_id + suggested_object_id = split_entity_id(entity.entity_id)[1] + else: + if device and entity.has_entity_name: # type: ignore[unreachable] + device_name = device.name_by_user or device.name + if not entity.name: + suggested_object_id = device_name + else: + suggested_object_id = f"{device_name} {entity.name}" + if not suggested_object_id: + suggested_object_id = entity.name + + if self.entity_namespace is not None: + suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" + disabled_by: RegistryEntryDisabler | None = None if not entity.entity_registry_enabled_default: disabled_by = RegistryEntryDisabler.INTEGRATION diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d03d272b1ac..ff38a48da75 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -60,7 +60,7 @@ SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 6 +STORAGE_VERSION_MINOR = 7 STORAGE_KEY = "core.entity_registry" # Attributes relevant to describing entity @@ -111,6 +111,7 @@ class RegistryEntry: hidden_by: RegistryEntryHider | None = attr.ib(default=None) icon: str | None = attr.ib(default=None) id: str = attr.ib(factory=uuid_util.random_uuid_hex) + has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) options: Mapping[str, Mapping[str, Any]] = attr.ib( default=None, converter=attr.converters.default_if_none(factory=dict) # type: ignore[misc] @@ -328,6 +329,7 @@ class EntityRegistry: config_entry: ConfigEntry | None = None, device_id: str | None = None, entity_category: EntityCategory | None = None, + has_entity_name: bool | None = None, original_device_class: str | None = None, original_icon: str | None = None, original_name: str | None = None, @@ -349,6 +351,9 @@ class EntityRegistry: config_entry_id=config_entry_id or UNDEFINED, device_id=device_id or UNDEFINED, entity_category=entity_category or UNDEFINED, + has_entity_name=has_entity_name + if has_entity_name is not None + else UNDEFINED, original_device_class=original_device_class or UNDEFINED, original_icon=original_icon or UNDEFINED, original_name=original_name or UNDEFINED, @@ -369,6 +374,8 @@ class EntityRegistry: if disabled_by and not isinstance(disabled_by, RegistryEntryDisabler): raise ValueError("disabled_by must be a RegistryEntryDisabler value") + if hidden_by and not isinstance(hidden_by, RegistryEntryHider): + raise ValueError("hidden_by must be a RegistryEntryHider value") if ( disabled_by is None @@ -391,6 +398,7 @@ class EntityRegistry: entity_category=entity_category, entity_id=entity_id, hidden_by=hidden_by, + has_entity_name=has_entity_name or False, original_device_class=original_device_class, original_icon=original_icon, original_name=original_name, @@ -497,6 +505,7 @@ class EntityRegistry: entity_category: EntityCategory | None | UndefinedType = UNDEFINED, hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + has_entity_name: bool | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -520,6 +529,12 @@ class EntityRegistry: and not isinstance(disabled_by, RegistryEntryDisabler) ): raise ValueError("disabled_by must be a RegistryEntryDisabler value") + if ( + hidden_by + and hidden_by is not UNDEFINED + and not isinstance(hidden_by, RegistryEntryHider) + ): + raise ValueError("hidden_by must be a RegistryEntryHider value") from .entity import EntityCategory # pylint: disable=import-outside-toplevel @@ -540,6 +555,7 @@ class EntityRegistry: ("entity_category", entity_category), ("hidden_by", hidden_by), ("icon", icon), + ("has_entity_name", has_entity_name), ("name", name), ("original_device_class", original_device_class), ("original_icon", original_icon), @@ -613,6 +629,7 @@ class EntityRegistry: entity_category: EntityCategory | None | UndefinedType = UNDEFINED, hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + has_entity_name: bool | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -634,6 +651,7 @@ class EntityRegistry: entity_category=entity_category, hidden_by=hidden_by, icon=icon, + has_entity_name=has_entity_name, name=name, new_entity_id=new_entity_id, new_unique_id=new_unique_id, @@ -729,9 +747,12 @@ class EntityRegistry: if entity["entity_category"] else None, entity_id=entity["entity_id"], - hidden_by=entity["hidden_by"], + hidden_by=RegistryEntryHider(entity["hidden_by"]) + if entity["hidden_by"] + else None, icon=entity["icon"], id=entity["id"], + has_entity_name=entity["has_entity_name"], name=entity["name"], options=entity["options"], original_device_class=entity["original_device_class"], @@ -768,6 +789,7 @@ class EntityRegistry: "hidden_by": entry.hidden_by, "icon": entry.icon, "id": entry.id, + "has_entity_name": entry.has_entity_name, "name": entry.name, "options": entry.options, "original_device_class": entry.original_device_class, @@ -934,6 +956,11 @@ async def _async_migrate( for entity in data["entities"]: entity["hidden_by"] = None + if old_major_version == 1 and old_minor_version < 7: + # Version 1.6 adds has_entity_name + for entity in data["entities"]: + entity["has_entity_name"] = False + if old_major_version > 1: raise NotImplementedError return data diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d4722eeca44..109c5454cc2 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -145,11 +145,7 @@ def _glob_to_re(glob: str) -> re.Pattern[str]: def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: """Test entity against list of patterns, true if any match.""" - for pattern in patterns: - if pattern.match(entity_id): - return True - - return False + return any(pattern.match(entity_id) for pattern in patterns) def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: @@ -193,7 +189,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in include_e or domain in include_d - or bool(include_eg and _test_against_patterns(include_eg, entity_id)) + or _test_against_patterns(include_eg, entity_id) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -201,14 +197,19 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in exclude_e or domain in exclude_d - or bool(exclude_eg and _test_against_patterns(exclude_eg, entity_id)) + or _test_against_patterns(exclude_eg, entity_id) ) - # Case 1 - no includes or excludes - pass all entities + # Case 1 - No filter + # - All entities included if not have_include and not have_exclude: return lambda entity_id: True - # Case 2 - includes, no excludes - only include specified entities + # Case 2 - Only includes + # - Entity listed in entities include: include + # - Otherwise, entity matches domain include: include + # - Otherwise, entity matches glob include: include + # - Otherwise: exclude if have_include and not have_exclude: def entity_filter_2(entity_id: str) -> bool: @@ -218,7 +219,11 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_filter_2 - # Case 3 - excludes, no includes - only exclude specified entities + # Case 3 - Only excludes + # - Entity listed in exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise: include if not have_include and have_exclude: def entity_filter_3(entity_id: str) -> bool: @@ -228,38 +233,36 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_filter_3 - # Case 4 - both includes and excludes specified - # Case 4a - include domain or glob specified - # - if domain is included, pass if entity not excluded - # - if glob is included, pass if entity and domain not excluded - # - if domain and glob are not included, pass if entity is included - # note: if both include domain matches then exclude domains ignored. - # If glob matches then exclude domains and glob checked + # Case 4 - Domain and/or glob includes (may also have excludes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in entities exclude: exclude + # - Otherwise, entity matches glob include: include + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain include: include + # - Otherwise: exclude if include_d or include_eg: def entity_filter_4a(entity_id: str) -> bool: """Return filter function for case 4a.""" - domain = split_entity_id(entity_id)[0] - if domain in include_d: - return not ( - entity_id in exclude_e - or bool( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) + return entity_id in include_e or ( + entity_id not in exclude_e + and ( + _test_against_patterns(include_eg, entity_id) + or ( + split_entity_id(entity_id)[0] in include_d + and not _test_against_patterns(exclude_eg, entity_id) ) ) - if _test_against_patterns(include_eg, entity_id): - return not entity_excluded(domain, entity_id) - return entity_id in include_e + ) return entity_filter_4a - # Case 4b - exclude domain or glob specified, include has no domain or glob - # In this one case the traditional include logic is inverted. Even though an - # include is specified since its only a list of entity IDs its used only to - # expose specific entities excluded by domain or glob. Any entities not - # excluded are then presumed included. Logic is as follows - # - if domain or glob is excluded, pass if entity is included - # - if domain is not excluded, pass if entity not excluded by ID + # Case 5 - Domain and/or glob excludes (no domain and/or glob includes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise: include if exclude_d or exclude_eg: def entity_filter_4b(entity_id: str) -> bool: @@ -273,6 +276,7 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_filter_4b - # Case 4c - neither include or exclude domain specified - # - Only pass if entity is included. Ignore entity excludes. + # Case 6 - No Domain and/or glob includes or excludes + # - Entity listed in entities include: include + # - Otherwise: exclude return lambda entity_id: entity_id in include_e diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c1229dc3e7c..85cd684fca1 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -258,7 +258,9 @@ def _async_track_state_change_event( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """async_track_state_change_event without lowercasing.""" - entity_callbacks = hass.data.setdefault(TRACK_STATE_CHANGE_CALLBACKS, {}) + entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_STATE_CHANGE_CALLBACKS, {} + ) if TRACK_STATE_CHANGE_LISTENER not in hass.data: @@ -319,10 +321,10 @@ def _async_remove_indexed_listeners( data_key: str, listener_key: str, storage_keys: Iterable[str], - job: HassJob[Any], + job: HassJob[[Event], Any], ) -> None: """Remove a listener.""" - callbacks = hass.data[data_key] + callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data[data_key] for storage_key in storage_keys: callbacks[storage_key].remove(job) @@ -347,7 +349,9 @@ def async_track_entity_registry_updated_event( if not (entity_ids := _async_string_to_lower_list(entity_ids)): return _remove_empty_listener - entity_callbacks = hass.data.setdefault(TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) + entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {} + ) if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: @@ -401,7 +405,7 @@ def async_track_entity_registry_updated_event( @callback def _async_dispatch_domain_event( - hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[Any]]] + hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]] ) -> None: domain = split_entity_id(event.data["entity_id"])[0] @@ -438,7 +442,9 @@ def _async_track_state_added_domain( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """async_track_state_added_domain without lowercasing.""" - domain_callbacks = hass.data.setdefault(TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {}) + domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {} + ) if TRACK_STATE_ADDED_DOMAIN_LISTENER not in hass.data: @@ -490,7 +496,9 @@ def async_track_state_removed_domain( if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener - domain_callbacks = hass.data.setdefault(TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {}) + domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {} + ) if TRACK_STATE_REMOVED_DOMAIN_LISTENER not in hass.data: @@ -1249,7 +1257,7 @@ track_same_state = threaded_listener_factory(async_track_same_state) @bind_hass def async_track_point_in_time( hass: HomeAssistant, - action: HassJob[Awaitable[None] | None] + action: HassJob[[datetime], Awaitable[None] | None] | Callable[[datetime], Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: @@ -1271,31 +1279,29 @@ track_point_in_time = threaded_listener_factory(async_track_point_in_time) @bind_hass def async_track_point_in_utc_time( hass: HomeAssistant, - action: HassJob[Awaitable[None] | None] + action: HassJob[[datetime], Awaitable[None] | None] | Callable[[datetime], Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) + expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) # Since this is called once, we accept a HassJob so we can avoid # having to figure out how to call the action every time its called. cancel_callback: asyncio.TimerHandle | None = None @callback - def run_action(job: HassJob[Awaitable[None] | None]) -> None: + def run_action(job: HassJob[[datetime], Awaitable[None] | None]) -> None: """Call the action.""" nonlocal cancel_callback - - now = time_tracker_utcnow() - # Depending on the available clock support (including timer hardware # and the OS kernel) it can happen that we fire a little bit too early # as measured by utcnow(). That is bad when callbacks have assumptions # about the current time. Thus, we rearm the timer for the remaining # time. - if (delta := (utc_point_in_time - now).total_seconds()) > 0: + if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) cancel_callback = hass.loop.call_later(delta, run_action, job) @@ -1324,7 +1330,7 @@ track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_tim def async_call_later( hass: HomeAssistant, delay: float | timedelta, - action: HassJob[Awaitable[None] | None] + action: HassJob[[datetime], Awaitable[None] | None] | Callable[[datetime], Awaitable[None] | None], ) -> CALLBACK_TYPE: """Add a listener that is called in .""" @@ -1345,7 +1351,7 @@ def async_track_time_interval( ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove: CALLBACK_TYPE - interval_listener_job: HassJob[None] + interval_listener_job: HassJob[[datetime], None] job = HassJob(action) @@ -1382,7 +1388,7 @@ class SunListener: """Helper class to help listen to sun events.""" hass: HomeAssistant = attr.ib() - job: HassJob[Awaitable[None] | None] = attr.ib() + job: HassJob[[], Awaitable[None] | None] = attr.ib() event: str = attr.ib() offset: timedelta | None = attr.ib() _unsub_sun: CALLBACK_TYPE | None = attr.ib(default=None) @@ -1466,6 +1472,7 @@ track_sunset = threaded_listener_factory(async_track_sunset) # For targeted patching in tests time_tracker_utcnow = dt_util.utcnow +time_tracker_timestamp = time.time @callback diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index c581e5a9361..74a2f542910 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -1,7 +1,13 @@ """Helpers to help with encoding Home Assistant objects in JSON.""" import datetime import json -from typing import Any +from pathlib import Path +from typing import Any, Final + +import orjson + +JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError) +JSON_DECODE_EXCEPTIONS = (orjson.JSONDecodeError,) class JSONEncoder(json.JSONEncoder): @@ -22,6 +28,22 @@ class JSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, o) +def json_encoder_default(obj: Any) -> Any: + """Convert Home Assistant objects. + + Hand other objects to the original method. + """ + if isinstance(obj, (set, tuple)): + return list(obj) + if isinstance(obj, float): + return float(obj) + if hasattr(obj, "as_dict"): + return obj.as_dict() + if isinstance(obj, Path): + return obj.as_posix() + raise TypeError + + class ExtendedJSONEncoder(JSONEncoder): """JSONEncoder that supports Home Assistant objects and falls back to repr(o).""" @@ -40,3 +62,43 @@ class ExtendedJSONEncoder(JSONEncoder): return super().default(o) except TypeError: return {"__type": str(type(o)), "repr": repr(o)} + + +def json_bytes(data: Any) -> bytes: + """Dump json bytes.""" + return orjson.dumps( + data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ) + + +def json_dumps(data: Any) -> str: + """Dump json string. + + orjson supports serializing dataclasses natively which + eliminates the need to implement as_dict in many places + when the data is already in a dataclass. This works + well as long as all the data in the dataclass can also + be serialized. + + If it turns out to be a problem we can disable this + with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it + will fallback to as_dict + """ + return orjson.dumps( + data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ).decode("utf-8") + + +def json_dumps_sorted(data: Any) -> str: + """Dump json string with keys sorted.""" + return orjson.dumps( + data, + option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SORT_KEYS, + default=json_encoder_default, + ).decode("utf-8") + + +json_loads = orjson.loads + + +JSON_DUMP: Final = json_dumps diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 4073421bc2c..12ffa7cc101 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -42,7 +42,7 @@ class SchemaFlowFormStep: # The next_step function is called if the schema validates successfully or if no # schema is defined. The next_step function is passed the union of config entry # options and user input from previous steps. - # If next_step returns None, the flow is ended with RESULT_TYPE_CREATE_ENTRY. + # If next_step returns None, the flow is ended with FlowResultType.CREATE_ENTRY. next_step: Callable[[dict[str, Any]], str | None] = lambda _: None # Optional function to allow amending a form schema. diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 87574949f4e..ccb7ac67dfb 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -5,13 +5,12 @@ from collections.abc import Callable, Sequence from typing import Any, TypedDict, cast import voluptuous as vol -import yaml from homeassistant.backports.enum import StrEnum from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import split_entity_id, valid_entity_id from homeassistant.util import decorator -from homeassistant.util.yaml.dumper import represent_odict +from homeassistant.util.yaml.dumper import add_representer, represent_odict from . import config_validation as cv @@ -889,7 +888,7 @@ class TimeSelector(Selector): return cast(str, data) -yaml.SafeDumper.add_representer( +add_representer( Selector, lambda dumper, value: represent_odict( dumper, "tag:yaml.org,2002:map", value.serialize() diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 053beab307e..36f5dc9b22c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,11 +5,11 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Generator, Iterable +from collections.abc import Callable, Collection, Generator, Iterable from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta -from functools import partial, wraps +from functools import cache, lru_cache, partial, wraps import json import logging import math @@ -19,7 +19,7 @@ import re import statistics from struct import error as StructError, pack, unpack_from import sys -from typing import Any, cast +from typing import Any, NoReturn, TypeVar, cast, overload from urllib.parse import urlencode as urllib_urlencode import weakref @@ -54,9 +54,11 @@ from homeassistant.util import ( slugify as slugify_util, ) from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from . import area_registry, device_registry, entity_registry, location as loc_helper +from .json import JSON_DECODE_EXCEPTIONS, json_loads from .typing import TemplateVarsType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -90,6 +92,8 @@ _COLLECTABLE_STATE_ATTRIBUTES = { "name", } +_T = TypeVar("_T") + ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) @@ -97,6 +101,9 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +CACHED_TEMPLATE_STATES = 512 +EVAL_CACHE_SIZE = 512 + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -221,6 +228,9 @@ def _false(arg: str) -> bool: return False +_cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval) + + class RenderInfo: """Holds information about a template render.""" @@ -317,6 +327,7 @@ class Template: "_exc_info", "_limited", "_strict", + "_hash_cache", ) def __init__(self, template, hass=None): @@ -332,6 +343,7 @@ class Template: self._exc_info = None self._limited = None self._strict = None + self._hash_cache: int = hash(self.template) @property def _env(self) -> TemplateEnvironment: @@ -420,7 +432,7 @@ class Template: def _parse_result(self, render_result: str) -> Any: """Parse the result.""" try: - result = literal_eval(render_result) + result = _cached_literal_eval(render_result) if type(result) in RESULT_WRAPPERS: result = RESULT_WRAPPERS[type(result)]( @@ -565,8 +577,8 @@ class Template: variables = dict(variables or {}) variables["value"] = value - with suppress(ValueError, TypeError): - variables["value_json"] = json.loads(value) + with suppress(*JSON_DECODE_EXCEPTIONS): + variables["value_json"] = json_loads(value) try: return _render_with_context( @@ -617,16 +629,30 @@ class Template: def __hash__(self) -> int: """Hash code for template.""" - return hash(self.template) + return self._hash_cache def __repr__(self) -> str: """Representation of Template.""" return 'Template("' + self.template + '")' +@cache +def _domain_states(hass: HomeAssistant, name: str) -> DomainStates: + return DomainStates(hass, name) + + +def _readonly(*args: Any, **kwargs: Any) -> Any: + """Raise an exception when a states object is modified.""" + raise RuntimeError(f"Cannot modify template States object: {args} {kwargs}") + + class AllStates: """Class to expose all HA states as attributes.""" + __setitem__ = _readonly + __delitem__ = _readonly + __slots__ = ("_hass",) + def __init__(self, hass: HomeAssistant) -> None: """Initialize all states.""" self._hass = hass @@ -642,7 +668,7 @@ class AllStates: if not valid_entity_id(f"{name}.entity"): raise TemplateError(f"Invalid domain name '{name}'") - return DomainStates(self._hass, name) + return _domain_states(self._hass, name) # Jinja will try __getitem__ first and it avoids the need # to call is_safe_attribute @@ -681,6 +707,11 @@ class AllStates: class DomainStates: """Class to expose a specific HA domain as attributes.""" + __slots__ = ("_hass", "_domain") + + __setitem__ = _readonly + __delitem__ = _readonly + def __init__(self, hass: HomeAssistant, domain: str) -> None: """Initialize the domain states.""" self._hass = hass @@ -726,6 +757,9 @@ class TemplateStateBase(State): _state: State + __setitem__ = _readonly + __delitem__ = _readonly + # Inheritance is done so functions that check against State keep working # pylint: disable=super-init-not-called def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: @@ -733,6 +767,7 @@ class TemplateStateBase(State): self._hass = hass self._collect = collect self._entity_id = entity_id + self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None def _collect_state(self) -> None: if self._collect and _RENDER_INFO in self._hass.data: @@ -864,10 +899,15 @@ def _collect_state(hass: HomeAssistant, entity_id: str) -> None: entity_collect.entities.add(entity_id) +@lru_cache(maxsize=CACHED_TEMPLATE_STATES) +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + return TemplateState(hass, state, collect=False) + + def _state_generator(hass: HomeAssistant, domain: str | None) -> Generator: """State generator for a domain or all states.""" for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): - yield TemplateState(hass, state, collect=False) + yield _template_state_no_collect(hass, state) def _get_state_if_valid(hass: HomeAssistant, entity_id: str) -> TemplateState | None: @@ -881,6 +921,11 @@ def _get_state(hass: HomeAssistant, entity_id: str) -> TemplateState | None: return _get_template_state_from_state(hass, entity_id, hass.states.get(entity_id)) +@lru_cache(maxsize=CACHED_TEMPLATE_STATES) +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + return TemplateState(hass, state) + + def _get_template_state_from_state( hass: HomeAssistant, entity_id: str, state: State | None ) -> TemplateState | None: @@ -889,7 +934,7 @@ def _get_template_state_from_state( # access to the state properties in the state wrapper. _collect_state(hass, entity_id) return None - return TemplateState(hass, state) + return _template_state(hass, state) def _resolve_state( @@ -903,6 +948,31 @@ def _resolve_state( return None +@overload +def forgiving_boolean(value: Any) -> bool | object: + ... + + +@overload +def forgiving_boolean(value: Any, default: _T) -> bool | _T: + ... + + +def forgiving_boolean( + value: Any, default: _T | object = _SENTINEL +) -> bool | _T | object: + """Try to convert value to a boolean.""" + try: + # Import here, not at top-level to avoid circular import + from . import config_validation as cv # pylint: disable=import-outside-toplevel + + return cv.boolean(value) + except vol.Invalid: + if default is _SENTINEL: + raise_no_default("bool", value) + return default + + def result_as_boolean(template_result: Any | None) -> bool: """Convert the template result to a boolean. @@ -913,13 +983,7 @@ def result_as_boolean(template_result: Any | None) -> bool: if template_result is None: return False - try: - # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel - - return cv.boolean(template_result) - except vol.Invalid: - return False + return forgiving_boolean(template_result, default=False) def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: @@ -1325,7 +1389,7 @@ def utcnow(hass: HomeAssistant) -> datetime: return dt_util.utcnow() -def raise_no_default(function, value): +def raise_no_default(function: str, value: Any) -> NoReturn: """Log warning if no default is specified.""" template, action = template_cv.get() or ("", "rendering or compiling") raise ValueError( @@ -1743,7 +1807,7 @@ def ordinal(value): def from_json(value): """Convert a JSON string to an object.""" - return json.loads(value) + return json_loads(value) def to_json(value, ensure_ascii=True): @@ -1938,6 +2002,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["relative_time"] = relative_time self.filters["slugify"] = slugify self.filters["iif"] = iif + self.filters["bool"] = forgiving_boolean self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -1969,6 +2034,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["unpack"] = struct_unpack self.globals["slugify"] = slugify self.globals["iif"] = iif + self.globals["bool"] = forgiving_boolean self.tests["is_number"] = is_number self.tests["match"] = regex_match self.tests["search"] = regex_search diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py new file mode 100644 index 00000000000..83d321e3fa9 --- /dev/null +++ b/homeassistant/helpers/template_entity.py @@ -0,0 +1,434 @@ +"""TemplateEntity utility class.""" +from __future__ import annotations + +from collections.abc import Callable +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA, + SensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + STATE_UNKNOWN, +) +from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback +from homeassistant.exceptions import TemplateError + +from . import config_validation as cv +from .entity import Entity +from .event import TrackTemplate, TrackTemplateResult, async_track_template_result +from .script import Script, _VarsType +from .template import Template, TemplateStateFromEntityId, result_as_boolean +from .typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CONF_AVAILABILITY = "availability" +CONF_ATTRIBUTES = "attributes" +CONF_PICTURE = "picture" + +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + +TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) + + +class _TemplateAttribute: + """Attribute value linked to template result.""" + + def __init__( + self, + entity: Entity, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool | None = False, + ) -> None: + """Template attribute.""" + self._entity = entity + self._attribute = attribute + self.template = template + self.validator = validator + self.on_update = on_update + self.async_update = None + self.none_on_template_error = none_on_template_error + + @callback + def async_setup(self) -> None: + """Config update path for the attribute.""" + if self.on_update: + return + + if not hasattr(self._entity, self._attribute): + raise AttributeError(f"Attribute '{self._attribute}' does not exist.") + + self.on_update = self._default_update + + @callback + def _default_update(self, result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + setattr(self._entity, self._attribute, attr_result) + + @callback + def handle_result( + self, + event: Event | None, + template: Template, + last_result: str | None | TemplateError, + result: str | TemplateError, + ) -> None: + """Handle a template result event callback.""" + if isinstance(result, TemplateError): + _LOGGER.error( + "TemplateError('%s') " + "while processing template '%s' " + "for attribute '%s' in entity '%s'", + result, + self.template, + self._attribute, + self._entity.entity_id, + ) + if self.none_on_template_error: + self._default_update(result) + else: + assert self.on_update + self.on_update(result) + return + + if not self.validator: + assert self.on_update + self.on_update(result) + return + + try: + validated = self.validator(result) + except vol.Invalid as ex: + _LOGGER.error( + "Error validating template result '%s' " + "from template '%s' " + "for attribute '%s' in entity %s " + "validation message '%s'", + result, + self.template, + self._attribute, + self._entity.entity_id, + ex.msg, + ) + assert self.on_update + self.on_update(None) + return + + assert self.on_update + self.on_update(validated) + return + + +class TemplateEntity(Entity): + """Entity that uses templates to calculate attributes.""" + + _attr_available = True + _attr_entity_picture = None + _attr_icon = None + + def __init__( + self, + hass: HomeAssistant, + *, + availability_template: Template | None = None, + icon_template: Template | None = None, + entity_picture_template: Template | None = None, + attribute_templates: dict[str, Template] | None = None, + config: ConfigType | None = None, + fallback_name: str | None = None, + unique_id: str | None = None, + ) -> None: + """Template Entity.""" + self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} + self._async_update: Callable[[], None] | None = None + self._attr_extra_state_attributes = {} + self._self_ref_update_count = 0 + self._attr_unique_id = unique_id + if config is None: + self._attribute_templates = attribute_templates + self._availability_template = availability_template + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + self._friendly_name_template = None + else: + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + + class DummyState(State): + """None-state for template entities not yet added to the state machine.""" + + def __init__(self) -> None: + """Initialize a new state.""" + super().__init__("unknown.unknown", STATE_UNKNOWN) + self.entity_id = None # type: ignore[assignment] + + @property + def name(self) -> str: + """Name of this state.""" + return "" + + variables = {"this": DummyState()} + + # Try to render the name as it can influence the entity ID + self._attr_name = fallback_name + if self._friendly_name_template: + self._friendly_name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = self._friendly_name_template.async_render( + variables=variables, parse_result=False + ) + + # Templates will not render while the entity is unavailable, try to render the + # icon and picture templates. + if self._entity_picture_template: + self._entity_picture_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_entity_picture = self._entity_picture_template.async_render( + variables=variables, parse_result=False + ) + + if self._icon_template: + self._icon_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_icon = self._icon_template.async_render( + variables=variables, parse_result=False + ) + + @callback + def _update_available(self, result: str | TemplateError) -> None: + if isinstance(result, TemplateError): + self._attr_available = True + return + + self._attr_available = result_as_boolean(result) + + @callback + def _update_state(self, result: str | TemplateError) -> None: + if self._availability_template: + return + + self._attr_available = not isinstance(result, TemplateError) + + @callback + def _add_attribute_template( + self, attribute_key: str, attribute_template: Template + ) -> None: + """Create a template tracker for the attribute.""" + + def _update_attribute(result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + self._attr_extra_state_attributes[attribute_key] = attr_result + + self.add_template_attribute( + attribute_key, attribute_template, None, _update_attribute + ) + + def add_template_attribute( + self, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool = False, + ) -> None: + """ + Call in the constructor to add a template linked to a attribute. + + Parameters + ---------- + attribute + The name of the attribute to link to. This attribute must exist + unless a custom on_update method is supplied. + template + The template to calculate. + validator + Validator function to parse the result and ensure it's valid. + on_update + Called to store the template result rather than storing it + the supplied attribute. Passed the result of the validator, or None + if the template or validator resulted in an error. + + """ + assert self.hass is not None, "hass cannot be None" + template.hass = self.hass + template_attribute = _TemplateAttribute( + self, attribute, template, validator, on_update, none_on_template_error + ) + self._template_attrs.setdefault(template, []) + self._template_attrs[template].append(template_attribute) + + @callback + def _handle_results( + self, + event: Event | None, + updates: list[TrackTemplateResult], + ) -> None: + """Call back the results to the attributes.""" + if event: + self.async_set_context(event.context) + + entity_id = event and event.data.get(ATTR_ENTITY_ID) + + if entity_id and entity_id == self.entity_id: + self._self_ref_update_count += 1 + else: + self._self_ref_update_count = 0 + + if self._self_ref_update_count > len(self._template_attrs): + for update in updates: + _LOGGER.warning( + "Template loop detected while processing event: %s, skipping template render for Template[%s]", + event, + update.template.template, + ) + return + + for update in updates: + for attr in self._template_attrs[update.template]: + attr.handle_result( + event, update.template, update.last_result, update.result + ) + + self.async_write_ha_state() + + async def _async_template_startup(self, *_: Any) -> None: + template_var_tups: list[TrackTemplate] = [] + has_availability_template = False + + variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} + + for template, attributes in self._template_attrs.items(): + template_var_tup = TrackTemplate(template, variables) + is_availability_template = False + for attribute in attributes: + # pylint: disable-next=protected-access + if attribute._attribute == "_attr_available": + has_availability_template = True + is_availability_template = True + attribute.async_setup() + # Insert the availability template first in the list + if is_availability_template: + template_var_tups.insert(0, template_var_tup) + else: + template_var_tups.append(template_var_tup) + + result_info = async_track_template_result( + self.hass, + template_var_tups, + self._handle_results, + has_super_template=has_availability_template, + ) + self.async_on_remove(result_info.async_remove) + self._async_update = result_info.async_refresh + result_info.async_refresh() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + if self._availability_template is not None: + self.add_template_attribute( + "_attr_available", + self._availability_template, + None, + self._update_available, + ) + if self._attribute_templates is not None: + for key, value in self._attribute_templates.items(): + self._add_attribute_template(key, value) + if self._icon_template is not None: + self.add_template_attribute( + "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) + ) + if self._entity_picture_template is not None: + self.add_template_attribute( + "_attr_entity_picture", self._entity_picture_template + ) + if ( + self._friendly_name_template is not None + and not self._friendly_name_template.is_static + ): + self.add_template_attribute("_attr_name", self._friendly_name_template) + + if self.hass.state == CoreState.running: + await self._async_template_startup() + return + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._async_template_startup + ) + + async def async_update(self) -> None: + """Call for forced update.""" + assert self._async_update + self._async_update() + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + return await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **run_variables, + }, + context=context, + ) + + +class TemplateSensor(TemplateEntity, SensorEntity): + """Representation of a Template Sensor.""" + + def __init__( + self, + hass: HomeAssistant, + *, + config: dict[str, Any], + fallback_name: str | None, + unique_id: str | None, + ) -> None: + """Initialize the sensor.""" + super().__init__( + hass, config=config, fallback_name=fallback_name, unique_id=unique_id + ) + + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index f7ad8e013cb..fc619469500 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -2,18 +2,18 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Generator from datetime import datetime, timedelta import logging from time import monotonic -from typing import Any, Generic, TypeVar # pylint: disable=unused-import +from typing import Any, Generic, TypeVar import urllib.error import aiohttp import requests from homeassistant import config_entries -from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.util.dt import utcnow @@ -61,7 +61,7 @@ class DataUpdateCoordinator(Generic[_T]): # when it was already checked during setup. self.data: _T = None # type: ignore[assignment] - self._listeners: list[CALLBACK_TYPE] = [] + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} self._job = HassJob(self._handle_refresh_interval) self._unsub_refresh: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None @@ -82,32 +82,46 @@ class DataUpdateCoordinator(Generic[_T]): self._debounced_refresh = request_refresh_debouncer @callback - def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: """Listen for data updates.""" schedule_refresh = not self._listeners - self._listeners.append(update_callback) + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + if not self._listeners: + self._unschedule_refresh() + + self._listeners[remove_listener] = (update_callback, context) # This is the first listener, set up interval. if schedule_refresh: self._schedule_refresh() - @callback - def remove_listener() -> None: - """Remove update listener.""" - self.async_remove_listener(update_callback) - return remove_listener @callback - def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: - """Remove data update.""" - self._listeners.remove(update_callback) + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() - if not self._listeners and self._unsub_refresh: + @callback + def _unschedule_refresh(self) -> None: + """Unschedule any pending refresh since there is no longer any listeners.""" + if self._unsub_refresh: self._unsub_refresh() self._unsub_refresh = None + def async_contexts(self) -> Generator[Any, None, None]: + """Return all registered contexts.""" + yield from ( + context for _, context in self._listeners.values() if context is not None + ) + @callback def _schedule_refresh(self) -> None: """Schedule a refresh.""" @@ -266,8 +280,7 @@ class DataUpdateCoordinator(Generic[_T]): if not auth_failed and self._listeners and not self.hass.is_stopping: self._schedule_refresh() - for update_callback in self._listeners: - update_callback() + self.async_update_listeners() @callback def async_set_updated_data(self, data: _T) -> None: @@ -288,24 +301,18 @@ class DataUpdateCoordinator(Generic[_T]): if self._listeners: self._schedule_refresh() - for update_callback in self._listeners: - update_callback() - - @callback - def _async_stop_refresh(self, _: Event) -> None: - """Stop refreshing when Home Assistant is stopping.""" - self.update_interval = None - if self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None + self.async_update_listeners() class CoordinatorEntity(entity.Entity, Generic[_DataUpdateCoordinatorT]): """A class for entities using DataUpdateCoordinator.""" - def __init__(self, coordinator: _DataUpdateCoordinatorT) -> None: + def __init__( + self, coordinator: _DataUpdateCoordinatorT, context: Any = None + ) -> None: """Create the entity with a DataUpdateCoordinator.""" self.coordinator = coordinator + self.coordinator_context = context @property def should_poll(self) -> bool: @@ -321,7 +328,9 @@ class CoordinatorEntity(entity.Entity, Generic[_DataUpdateCoordinatorT]): """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.async_add_listener(self._handle_coordinator_update) + self.coordinator.async_add_listener( + self._handle_coordinator_update, self.coordinator_context + ) ) @callback diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 589f316532b..ab681d7c42d 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -11,7 +11,6 @@ from collections.abc import Callable from contextlib import suppress import functools as ft import importlib -import json import logging import pathlib import sys @@ -30,6 +29,7 @@ from .generated.mqtt import MQTT from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF +from .helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from .util.async_ import gather_with_concurrency # Typing imports that create a circular dependency @@ -366,8 +366,8 @@ class Integration: continue try: - manifest = json.loads(manifest_path.read_text()) - except ValueError as err: + manifest = json_loads(manifest_path.read_text()) + except JSON_DECODE_EXCEPTIONS as err: _LOGGER.error( "Error parsing manifest.json file at %s: %s", manifest_path, err ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bec79680e0d..c291d969219 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,22 +4,23 @@ aiodiscover==1.4.11 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.31.1 +async-upnp-client==0.31.2 async_timeout==4.0.2 atomicwrites==1.4.0 attrs==21.2.0 -awesomeversion==22.5.2 +awesomeversion==22.6.0 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220601.0 +home-assistant-frontend==20220706.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.7 +orjson==3.7.5 paho-mqtt==1.6.1 pillow==9.1.1 pip>=21.0,<22.2 @@ -27,14 +28,14 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.22.0 pyyaml==6.0 -requests==2.27.1 +requests==2.28.1 scapy==2.4.5 -sqlalchemy==1.4.37 +sqlalchemy==1.4.38 typing-extensions>=3.10.0.2,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.13.1 yarl==1.7.2 -zeroconf==0.38.6 +zeroconf==0.38.7 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -86,6 +87,9 @@ httpcore==0.15.0 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 +# Ensure we run compatible with musllinux build env +numpy==1.23.0 + # pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead pytest_asyncio==1000000000.0.0 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 7631586d626..bd06cf61e4b 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -15,10 +15,7 @@ from .util import package as pkg_util PIP_TIMEOUT = 60 # The default is too low when the internet connection is satellite or high latency MAX_INSTALL_FAILURES = 3 -DATA_PIP_LOCK = "pip_lock" -DATA_PKG_CACHE = "pkg_cache" -DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" -DATA_INSTALL_FAILURE_HISTORY = "install_failure_history" +DATA_REQUIREMENTS_MANAGER = "requirements_manager" CONSTRAINT_FILE = "package_constraints.txt" DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = { "dhcp": ("dhcp",), @@ -40,7 +37,7 @@ class RequirementsNotFound(HomeAssistantError): async def async_get_integration_with_requirements( - hass: HomeAssistant, domain: str, done: set[str] | None = None + hass: HomeAssistant, domain: str ) -> Integration: """Get an integration with all requirements installed, including the dependencies. @@ -48,97 +45,8 @@ async def async_get_integration_with_requirements( is invalid, RequirementNotFound if there was some type of failure to install requirements. """ - if done is None: - done = {domain} - else: - done.add(domain) - - integration = await async_get_integration(hass, domain) - - if hass.config.skip_pip: - return integration - - if (cache := hass.data.get(DATA_INTEGRATIONS_WITH_REQS)) is None: - cache = hass.data[DATA_INTEGRATIONS_WITH_REQS] = {} - - int_or_evt: Integration | asyncio.Event | None | UndefinedType = cache.get( - domain, UNDEFINED - ) - - if isinstance(int_or_evt, asyncio.Event): - await int_or_evt.wait() - - # When we have waited and it's UNDEFINED, it doesn't exist - # We don't cache that it doesn't exist, or else people can't fix it - # and then restart, because their config will never be valid. - if (int_or_evt := cache.get(domain, UNDEFINED)) is UNDEFINED: - raise IntegrationNotFound(domain) - - if int_or_evt is not UNDEFINED: - return cast(Integration, int_or_evt) - - event = cache[domain] = asyncio.Event() - - try: - await _async_process_integration(hass, integration, done) - except Exception: - del cache[domain] - event.set() - raise - - cache[domain] = integration - event.set() - return integration - - -async def _async_process_integration( - hass: HomeAssistant, integration: Integration, done: set[str] -) -> None: - """Process an integration and requirements.""" - if integration.requirements: - await async_process_requirements( - hass, integration.domain, integration.requirements - ) - - deps_to_check = [ - dep - for dep in integration.dependencies + integration.after_dependencies - if dep not in done - ] - - for check_domain, to_check in DISCOVERY_INTEGRATIONS.items(): - if ( - check_domain not in done - and check_domain not in deps_to_check - and any(check in integration.manifest for check in to_check) - ): - deps_to_check.append(check_domain) - - if not deps_to_check: - return - - results = await asyncio.gather( - *( - async_get_integration_with_requirements(hass, dep, done) - for dep in deps_to_check - ), - return_exceptions=True, - ) - for result in results: - if not isinstance(result, BaseException): - continue - if not isinstance(result, IntegrationNotFound) or not ( - not integration.is_built_in - and result.domain in integration.after_dependencies - ): - raise result - - -@callback -def async_clear_install_history(hass: HomeAssistant) -> None: - """Forget the install history.""" - if install_failure_history := hass.data.get(DATA_INSTALL_FAILURE_HISTORY): - install_failure_history.clear() + manager = _async_get_manager(hass) + return await manager.async_get_integration_with_requirements(domain) async def async_process_requirements( @@ -149,49 +57,24 @@ async def async_process_requirements( This method is a coroutine. It will raise RequirementsNotFound if an requirement can't be satisfied. """ - if (pip_lock := hass.data.get(DATA_PIP_LOCK)) is None: - pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock() - install_failure_history = hass.data.get(DATA_INSTALL_FAILURE_HISTORY) - if install_failure_history is None: - install_failure_history = hass.data[DATA_INSTALL_FAILURE_HISTORY] = set() - - kwargs = pip_kwargs(hass.config.config_dir) - - async with pip_lock: - for req in requirements: - await _async_process_requirements( - hass, name, req, install_failure_history, kwargs - ) + await _async_get_manager(hass).async_process_requirements(name, requirements) -async def _async_process_requirements( - hass: HomeAssistant, - name: str, - req: str, - install_failure_history: set[str], - kwargs: Any, -) -> None: - """Install a requirement and save failures.""" - if req in install_failure_history: - _LOGGER.info( - "Multiple attempts to install %s failed, install will be retried after next configuration check or restart", - req, - ) - raise RequirementsNotFound(name, [req]) +@callback +def _async_get_manager(hass: HomeAssistant) -> RequirementsManager: + """Get the requirements manager.""" + if DATA_REQUIREMENTS_MANAGER in hass.data: + manager: RequirementsManager = hass.data[DATA_REQUIREMENTS_MANAGER] + return manager - if pkg_util.is_installed(req): - return + manager = hass.data[DATA_REQUIREMENTS_MANAGER] = RequirementsManager(hass) + return manager - def _install(req: str, kwargs: dict[str, Any]) -> bool: - """Install requirement.""" - return pkg_util.install_package(req, **kwargs) - for _ in range(MAX_INSTALL_FAILURES): - if await hass.async_add_executor_job(_install, req, kwargs): - return - - install_failure_history.add(req) - raise RequirementsNotFound(name, [req]) +@callback +def async_clear_install_history(hass: HomeAssistant) -> None: + """Forget the install history.""" + _async_get_manager(hass).install_failure_history.clear() def pip_kwargs(config_dir: str | None) -> dict[str, Any]: @@ -207,3 +90,178 @@ def pip_kwargs(config_dir: str | None) -> dict[str, Any]: if not (config_dir is None or pkg_util.is_virtual_env()) and not is_docker: kwargs["target"] = os.path.join(config_dir, "deps") return kwargs + + +def _install_with_retry(requirement: str, kwargs: dict[str, Any]) -> bool: + """Try to install a package up to MAX_INSTALL_FAILURES times.""" + for _ in range(MAX_INSTALL_FAILURES): + if pkg_util.install_package(requirement, **kwargs): + return True + return False + + +def _install_requirements_if_missing( + requirements: list[str], kwargs: dict[str, Any] +) -> tuple[set[str], set[str]]: + """Install requirements if missing.""" + installed: set[str] = set() + failures: set[str] = set() + for req in requirements: + if pkg_util.is_installed(req) or _install_with_retry(req, kwargs): + installed.add(req) + continue + failures.add(req) + return installed, failures + + +class RequirementsManager: + """Manage requirements.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the requirements manager.""" + self.hass = hass + self.pip_lock = asyncio.Lock() + self.integrations_with_reqs: dict[ + str, Integration | asyncio.Event | None | UndefinedType + ] = {} + self.install_failure_history: set[str] = set() + self.is_installed_cache: set[str] = set() + + async def async_get_integration_with_requirements( + self, domain: str, done: set[str] | None = None + ) -> Integration: + """Get an integration with all requirements installed, including the dependencies. + + This can raise IntegrationNotFound if manifest or integration + is invalid, RequirementNotFound if there was some type of + failure to install requirements. + """ + + if done is None: + done = {domain} + else: + done.add(domain) + + integration = await async_get_integration(self.hass, domain) + + if self.hass.config.skip_pip: + return integration + + cache = self.integrations_with_reqs + int_or_evt = cache.get(domain, UNDEFINED) + + if isinstance(int_or_evt, asyncio.Event): + await int_or_evt.wait() + + # When we have waited and it's UNDEFINED, it doesn't exist + # We don't cache that it doesn't exist, or else people can't fix it + # and then restart, because their config will never be valid. + if (int_or_evt := cache.get(domain, UNDEFINED)) is UNDEFINED: + raise IntegrationNotFound(domain) + + if int_or_evt is not UNDEFINED: + return cast(Integration, int_or_evt) + + event = cache[domain] = asyncio.Event() + + try: + await self._async_process_integration(integration, done) + except Exception: + del cache[domain] + event.set() + raise + + cache[domain] = integration + event.set() + return integration + + async def _async_process_integration( + self, integration: Integration, done: set[str] + ) -> None: + """Process an integration and requirements.""" + if integration.requirements: + await self.async_process_requirements( + integration.domain, integration.requirements + ) + + deps_to_check = [ + dep + for dep in integration.dependencies + integration.after_dependencies + if dep not in done + ] + + for check_domain, to_check in DISCOVERY_INTEGRATIONS.items(): + if ( + check_domain not in done + and check_domain not in deps_to_check + and any(check in integration.manifest for check in to_check) + ): + deps_to_check.append(check_domain) + + if not deps_to_check: + return + + results = await asyncio.gather( + *( + self.async_get_integration_with_requirements(dep, done) + for dep in deps_to_check + ), + return_exceptions=True, + ) + for result in results: + if not isinstance(result, BaseException): + continue + if not isinstance(result, IntegrationNotFound) or not ( + not integration.is_built_in + and result.domain in integration.after_dependencies + ): + raise result + + async def async_process_requirements( + self, name: str, requirements: list[str] + ) -> None: + """Install the requirements for a component or platform. + + This method is a coroutine. It will raise RequirementsNotFound + if an requirement can't be satisfied. + """ + if not (missing := self._find_missing_requirements(requirements)): + return + self._raise_for_failed_requirements(name, missing) + + async with self.pip_lock: + # Recaculate missing again now that we have the lock + missing = self._find_missing_requirements(requirements) + if missing: + await self._async_process_requirements(name, missing) + + def _find_missing_requirements(self, requirements: list[str]) -> list[str]: + """Find requirements that are missing in the cache.""" + return [req for req in requirements if req not in self.is_installed_cache] + + def _raise_for_failed_requirements( + self, integration: str, missing: list[str] + ) -> None: + """Raise RequirementsNotFound so we do not keep trying requirements that have already failed.""" + for req in missing: + if req in self.install_failure_history: + _LOGGER.info( + "Multiple attempts to install %s failed, install will be retried after next configuration check or restart", + req, + ) + raise RequirementsNotFound(integration, [req]) + + async def _async_process_requirements( + self, + name: str, + requirements: list[str], + ) -> None: + """Install a requirement and save failures.""" + kwargs = pip_kwargs(self.hass.config.config_dir) + installed, failures = await self.hass.async_add_executor_job( + _install_requirements_if_missing, requirements, kwargs + ) + self.is_installed_cache |= installed + self.install_failure_history |= failures + if failures: + raise RequirementsNotFound(name, list(failures)) diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index a681b3e210d..efbfec5e961 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -12,14 +12,13 @@ from timeit import default_timer as timer from typing import TypeVar from homeassistant import core -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.helpers.entityfilter import convert_include_exclude_filter from homeassistant.helpers.event import ( async_track_state_change, async_track_state_change_event, ) -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSON_DUMP, JSONEncoder # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs # mypy: no-warn-return-any diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 6182b909f74..cff40d2535c 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -13,6 +13,7 @@ from unittest.mock import patch from homeassistant import core from homeassistant.config import get_default_config_dir +from homeassistant.config_entries import ConfigEntries from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry, device_registry, entity_registry from homeassistant.helpers.check_config import async_check_ha_config_file @@ -191,7 +192,7 @@ def check(config_dir, secrets=False): if secrets: # Ensure !secrets point to the patched function - yaml_loader.SafeLineLoader.add_constructor("!secret", yaml_loader.secret_yaml) + yaml_loader.add_constructor("!secret", yaml_loader.secret_yaml) def secrets_proxy(*args): secrets = Secrets(*args) @@ -219,9 +220,7 @@ def check(config_dir, secrets=False): pat.stop() if secrets: # Ensure !secrets point to the original function - yaml_loader.SafeLineLoader.add_constructor( - "!secret", yaml_loader.secret_yaml - ) + yaml_loader.add_constructor("!secret", yaml_loader.secret_yaml) return res @@ -230,6 +229,7 @@ async def async_check_config(config_dir): """Check the HA config.""" hass = core.HomeAssistant() hass.config.config_dir = config_dir + hass.config_entries = ConfigEntries(hass, {}) await area_registry.async_load(hass) await device_registry.async_load(hass) await entity_registry.async_load(hass) diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index aa1aea1abc3..05ade335a53 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -3,13 +3,15 @@ from __future__ import annotations from http import HTTPStatus import io -import json from typing import Any from urllib.parse import parse_qsl from aiohttp import payload, web +from aiohttp.typedefs import JSONDecoder from multidict import CIMultiDict, MultiDict +from homeassistant.helpers.json import json_loads + class MockStreamReader: """Small mock to imitate stream reader.""" @@ -64,9 +66,9 @@ class MockRequest: """Return the body as text.""" return MockStreamReader(self._content) - async def json(self) -> Any: + async def json(self, loads: JSONDecoder = json_loads) -> Any: """Return the body as JSON.""" - return json.loads(self._text) + return loads(self._text) async def post(self) -> MultiDict[str]: """Return POST parameters.""" diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index fdee7a7a90f..d69a4106728 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -7,6 +7,8 @@ import json import logging from typing import Any +import orjson + from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError @@ -30,7 +32,7 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: """ try: with open(filename, encoding="utf-8") as fdesc: - return json.loads(fdesc.read()) # type: ignore[no-any-return] + return orjson.loads(fdesc.read()) # type: ignore[no-any-return] except FileNotFoundError: # This is not a fatal error _LOGGER.debug("JSON file not found: %s", filename) @@ -43,6 +45,13 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: return {} if default is None else default +def _orjson_encoder(data: Any) -> str: + """JSON encoder that uses orjson.""" + return orjson.dumps( + data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS + ).decode("utf-8") + + def save_json( filename: str, data: list | dict, @@ -55,10 +64,15 @@ def save_json( Returns True on success. """ + dump: Callable[[Any], Any] = json.dumps try: - json_data = json.dumps(data, indent=4, cls=encoder) + if encoder: + json_data = json.dumps(data, indent=2, cls=encoder) + else: + dump = _orjson_encoder + json_data = _orjson_encoder(data) except TypeError as error: - msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data))}" + msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data, dump=dump))}" _LOGGER.error(msg) raise SerializationError(msg) from error diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 87077a0eb0a..7d0d6e99639 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -14,14 +14,21 @@ LOOPBACK_NETWORKS = ( # RFC6890 - Address allocation for Private Internets PRIVATE_NETWORKS = ( - ip_network("fd00::/8"), ip_network("10.0.0.0/8"), ip_network("172.16.0.0/12"), ip_network("192.168.0.0/16"), + ip_network("fd00::/8"), + ip_network("::ffff:10.0.0.0/104"), + ip_network("::ffff:172.16.0.0/108"), + ip_network("::ffff:192.168.0.0/112"), ) # RFC6890 - Link local ranges -LINK_LOCAL_NETWORK = ip_network("169.254.0.0/16") +LINK_LOCAL_NETWORKS = ( + ip_network("169.254.0.0/16"), + ip_network("fe80::/10"), + ip_network("::ffff:169.254.0.0/112"), +) def is_loopback(address: IPv4Address | IPv6Address) -> bool: @@ -30,18 +37,18 @@ def is_loopback(address: IPv4Address | IPv6Address) -> bool: def is_private(address: IPv4Address | IPv6Address) -> bool: - """Check if an address is a private address.""" + """Check if an address is a unique local non-loopback address.""" return any(address in network for network in PRIVATE_NETWORKS) def is_link_local(address: IPv4Address | IPv6Address) -> bool: - """Check if an address is link local.""" - return address in LINK_LOCAL_NETWORK + """Check if an address is link-local (local but not necessarily unique).""" + return any(address in network for network in LINK_LOCAL_NETWORKS) def is_local(address: IPv4Address | IPv6Address) -> bool: - """Check if an address is loopback or private.""" - return is_loopback(address) or is_private(address) + """Check if an address is on a local network.""" + return is_loopback(address) or is_private(address) or is_link_local(address) def is_invalid(address: IPv4Address | IPv6Address) -> bool: diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py index f3fc652e90f..12618e020f8 100644 --- a/homeassistant/util/speed.py +++ b/homeassistant/util/speed.py @@ -5,9 +5,11 @@ from numbers import Number from homeassistant.const import ( SPEED, + SPEED_FEET_PER_SECOND, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_MILLIMETERS_PER_DAY, @@ -15,27 +17,33 @@ from homeassistant.const import ( ) VALID_UNITS: tuple[str, ...] = ( - SPEED_METERS_PER_SECOND, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, + SPEED_FEET_PER_SECOND, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, ) +FOOT_TO_M = 0.3048 HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +IN_TO_M = 0.0254 KM_TO_M = 1000 # 1 km = 1000 m -KM_TO_MILE = 0.62137119 # 1 km = 0.62137119 mi -M_TO_IN = 39.3700787 # 1 m = 39.3700787 in +MILE_TO_M = 1609.344 +NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m # Units in terms of m/s UNIT_CONVERSION: dict[str, float] = { - SPEED_METERS_PER_SECOND: 1, + SPEED_FEET_PER_SECOND: 1 / FOOT_TO_M, + SPEED_INCHES_PER_DAY: (24 * HRS_TO_SECS) / IN_TO_M, + SPEED_INCHES_PER_HOUR: HRS_TO_SECS / IN_TO_M, SPEED_KILOMETERS_PER_HOUR: HRS_TO_SECS / KM_TO_M, - SPEED_MILES_PER_HOUR: HRS_TO_SECS * KM_TO_MILE / KM_TO_M, + SPEED_KNOTS: HRS_TO_SECS / NAUTICAL_MILE_TO_M, + SPEED_METERS_PER_SECOND: 1, + SPEED_MILES_PER_HOUR: HRS_TO_SECS / MILE_TO_M, SPEED_MILLIMETERS_PER_DAY: (24 * HRS_TO_SECS) * 1000, - SPEED_INCHES_PER_DAY: (24 * HRS_TO_SECS) * M_TO_IN, - SPEED_INCHES_PER_HOUR: HRS_TO_SECS * M_TO_IN, } diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 3eafc8abdd7..9f69c6c346e 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -1,5 +1,6 @@ """Custom dumper and representers.""" from collections import OrderedDict +from typing import Any import yaml @@ -8,10 +9,20 @@ from .objects import Input, NodeListClass # mypy: allow-untyped-calls, no-warn-return-any +try: + from yaml import CSafeDumper as FastestAvailableSafeDumper +except ImportError: + from yaml import SafeDumper as FastestAvailableSafeDumper # type: ignore[misc] + + def dump(_dict: dict) -> str: """Dump YAML to a string and remove null.""" - return yaml.safe_dump( - _dict, default_flow_style=False, allow_unicode=True, sort_keys=False + return yaml.dump( + _dict, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + Dumper=FastestAvailableSafeDumper, ).replace(": null\n", ":\n") @@ -51,17 +62,22 @@ def represent_odict( # type: ignore[no-untyped-def] return node -yaml.SafeDumper.add_representer( +def add_representer(klass: Any, representer: Any) -> None: + """Add to representer to the dumper.""" + FastestAvailableSafeDumper.add_representer(klass, representer) + + +add_representer( OrderedDict, lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), ) -yaml.SafeDumper.add_representer( +add_representer( NodeListClass, lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), ) -yaml.SafeDumper.add_representer( +add_representer( Input, lambda dumper, value: dumper.represent_scalar("!input", value.name), ) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 3507ab96286..e3add3a7c44 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Iterator import fnmatch +from io import StringIO import logging import os from pathlib import Path @@ -11,6 +12,14 @@ from typing import Any, TextIO, TypeVar, Union, overload import yaml +try: + from yaml import CSafeLoader as FastestAvailableSafeLoader + + HAS_C_LOADER = True +except ImportError: + HAS_C_LOADER = False + from yaml import SafeLoader as FastestAvailableSafeLoader # type: ignore[misc] + from homeassistant.exceptions import HomeAssistantError from .const import SECRET_YAML @@ -88,6 +97,30 @@ class Secrets: return secrets +class SafeLoader(FastestAvailableSafeLoader): + """The fastest available safe loader.""" + + def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: + """Initialize a safe line loader.""" + self.stream = stream + if isinstance(stream, str): + self.name = "" + elif isinstance(stream, bytes): + self.name = "" + else: + self.name = getattr(stream, "name", "") + super().__init__(stream) + self.secrets = secrets + + def get_name(self) -> str: + """Get the name of the loader.""" + return self.name + + def get_stream_name(self) -> str: + """Get the name of the stream.""" + return self.stream.name or "" + + class SafeLineLoader(yaml.SafeLoader): """Loader class that keeps track of line numbers.""" @@ -103,6 +136,17 @@ class SafeLineLoader(yaml.SafeLoader): node.__line__ = last_line + 1 # type: ignore[attr-defined] return node + def get_name(self) -> str: + """Get the name of the loader.""" + return self.name + + def get_stream_name(self) -> str: + """Get the name of the stream.""" + return self.stream.name or "" + + +LoaderType = Union[SafeLineLoader, SafeLoader] + def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: """Load a YAML file.""" @@ -114,60 +158,90 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc -def parse_yaml(content: str | TextIO, secrets: Secrets | None = None) -> JSON_TYPE: - """Load a YAML file.""" +def parse_yaml( + content: str | TextIO | StringIO, secrets: Secrets | None = None +) -> JSON_TYPE: + """Parse YAML with the fastest available loader.""" + if not HAS_C_LOADER: + return _parse_yaml_pure_python(content, secrets) try: - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: SafeLineLoader(stream, secrets)) - or OrderedDict() - ) + return _parse_yaml(SafeLoader, content, secrets) + except yaml.YAMLError: + # Loading failed, so we now load with the slow line loader + # since the C one will not give us line numbers + if isinstance(content, (StringIO, TextIO)): + # Rewind the stream so we can try again + content.seek(0, 0) + return _parse_yaml_pure_python(content, secrets) + + +def _parse_yaml_pure_python( + content: str | TextIO | StringIO, secrets: Secrets | None = None +) -> JSON_TYPE: + """Parse YAML with the pure python loader (this is very slow).""" + try: + return _parse_yaml(SafeLineLoader, content, secrets) except yaml.YAMLError as exc: _LOGGER.error(str(exc)) raise HomeAssistantError(exc) from exc +def _parse_yaml( + loader: type[SafeLoader] | type[SafeLineLoader], + content: str | TextIO, + secrets: Secrets | None = None, +) -> JSON_TYPE: + """Load a YAML file.""" + # 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)) + or OrderedDict() + ) + + @overload def _add_reference( - obj: list | NodeListClass, loader: SafeLineLoader, node: yaml.nodes.Node + obj: list | NodeListClass, + loader: LoaderType, + node: yaml.nodes.Node, ) -> NodeListClass: ... @overload def _add_reference( - obj: str | NodeStrClass, loader: SafeLineLoader, node: yaml.nodes.Node + obj: str | NodeStrClass, + loader: LoaderType, + node: yaml.nodes.Node, ) -> NodeStrClass: ... @overload -def _add_reference( - obj: _DictT, loader: SafeLineLoader, node: yaml.nodes.Node -) -> _DictT: +def _add_reference(obj: _DictT, loader: LoaderType, node: yaml.nodes.Node) -> _DictT: ... -def _add_reference(obj, loader: SafeLineLoader, node: yaml.nodes.Node): # type: ignore[no-untyped-def] +def _add_reference(obj, loader: LoaderType, node: yaml.nodes.Node): # type: ignore[no-untyped-def] """Add file reference information to an object.""" if isinstance(obj, list): obj = NodeListClass(obj) if isinstance(obj, str): obj = NodeStrClass(obj) - setattr(obj, "__config_file__", loader.name) + setattr(obj, "__config_file__", loader.get_name()) setattr(obj, "__line__", node.start_mark.line) return obj -def _include_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: +def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load another YAML file and embeds it using the !include tag. Example: device_tracker: !include device_tracker.yaml """ - fname = os.path.join(os.path.dirname(loader.name), node.value) + fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: return _add_reference(load_yaml(fname, loader.secrets), loader, node) except FileNotFoundError as exc: @@ -191,12 +265,10 @@ def _find_files(directory: str, pattern: str) -> Iterator[str]: yield filename -def _include_dir_named_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node -) -> OrderedDict: +def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> OrderedDict: """Load multiple files from directory as a dictionary.""" mapping: OrderedDict = OrderedDict() - loc = os.path.join(os.path.dirname(loader.name), node.value) + loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: @@ -206,11 +278,11 @@ def _include_dir_named_yaml( def _include_dir_merge_named_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node + loader: LoaderType, node: yaml.nodes.Node ) -> OrderedDict: """Load multiple files from directory as a merged dictionary.""" mapping: OrderedDict = OrderedDict() - loc = os.path.join(os.path.dirname(loader.name), node.value) + loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): if os.path.basename(fname) == SECRET_YAML: continue @@ -221,10 +293,10 @@ def _include_dir_merge_named_yaml( def _include_dir_list_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node + loader: LoaderType, node: yaml.nodes.Node ) -> list[JSON_TYPE]: """Load multiple files from directory as a list.""" - loc = os.path.join(os.path.dirname(loader.name), node.value) + loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ load_yaml(f, loader.secrets) for f in _find_files(loc, "*.yaml") @@ -233,10 +305,10 @@ def _include_dir_list_yaml( def _include_dir_merge_list_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node + loader: LoaderType, node: yaml.nodes.Node ) -> JSON_TYPE: """Load multiple files from directory as a merged list.""" - loc: str = os.path.join(os.path.dirname(loader.name), node.value) + loc: str = os.path.join(os.path.dirname(loader.get_name()), node.value) merged_list: list[JSON_TYPE] = [] for fname in _find_files(loc, "*.yaml"): if os.path.basename(fname) == SECRET_YAML: @@ -247,7 +319,7 @@ def _include_dir_merge_list_yaml( return _add_reference(merged_list, loader, node) -def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> OrderedDict: +def _ordered_dict(loader: LoaderType, node: yaml.nodes.MappingNode) -> OrderedDict: """Load YAML mappings into an ordered dictionary to preserve key order.""" loader.flatten_mapping(node) nodes = loader.construct_pairs(node) @@ -259,14 +331,14 @@ def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> Order try: hash(key) except TypeError as exc: - fname = getattr(loader.stream, "name", "") + fname = loader.get_stream_name() raise yaml.MarkedYAMLError( context=f'invalid key: "{key}"', context_mark=yaml.Mark(fname, 0, line, -1, None, None), # type: ignore[arg-type] ) from exc if key in seen: - fname = getattr(loader.stream, "name", "") + fname = loader.get_stream_name() _LOGGER.warning( 'YAML file %s contains duplicate key "%s". Check lines %d and %d', fname, @@ -279,13 +351,13 @@ def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> Order return _add_reference(OrderedDict(nodes), loader, node) -def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: +def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Add line number and file name to Load YAML sequence.""" (obj,) = loader.construct_yaml_seq(node) return _add_reference(obj, loader, node) -def _env_var_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> str: +def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: """Load environment variables and embed it into the configuration YAML.""" args = node.value.split() @@ -298,27 +370,27 @@ def _env_var_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> str: raise HomeAssistantError(node.value) -def secret_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: +def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" if loader.secrets is None: raise HomeAssistantError("Secrets not supported in this YAML file") - return loader.secrets.get(loader.name, node.value) + return loader.secrets.get(loader.get_name(), node.value) -SafeLineLoader.add_constructor("!include", _include_yaml) -SafeLineLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict -) -SafeLineLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq -) -SafeLineLoader.add_constructor("!env_var", _env_var_yaml) -SafeLineLoader.add_constructor("!secret", secret_yaml) -SafeLineLoader.add_constructor("!include_dir_list", _include_dir_list_yaml) -SafeLineLoader.add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml) -SafeLineLoader.add_constructor("!include_dir_named", _include_dir_named_yaml) -SafeLineLoader.add_constructor( - "!include_dir_merge_named", _include_dir_merge_named_yaml -) -SafeLineLoader.add_constructor("!input", Input.from_node) +def add_constructor(tag: Any, constructor: Any) -> None: + """Add to constructor to all loaders.""" + for yaml_loader in (SafeLoader, SafeLineLoader): + yaml_loader.add_constructor(tag, constructor) + + +add_constructor("!include", _include_yaml) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) +add_constructor("!env_var", _env_var_yaml) +add_constructor("!secret", secret_yaml) +add_constructor("!include_dir_list", _include_dir_list_yaml) +add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml) +add_constructor("!include_dir_named", _include_dir_named_yaml) +add_constructor("!include_dir_merge_named", _include_dir_merge_named_yaml) +add_constructor("!input", Input.from_node) diff --git a/machine/khadas-vim3 b/machine/khadas-vim3 index be07d6c8aba..5aeaca50780 100644 --- a/machine/khadas-vim3 +++ b/machine/khadas-vim3 @@ -2,4 +2,9 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ - usbutils + usbutils \ + && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ + --use-deprecated=legacy-resolver diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index 9985b4a3b7a..6eed9e94142 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index 35c6eec77de..1647f91813c 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index 9985b4a3b7a..6eed9e94142 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index 35c6eec77de..1647f91813c 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/tinker b/machine/tinker index 9660ca71b9c..5976d533188 100644 --- a/machine/tinker +++ b/machine/tinker @@ -3,8 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ - --use-deprecated=legacy-resolver \ - bluepy \ pybluez \ - pygatt[GATTTOOL] + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ + --use-deprecated=legacy-resolver diff --git a/machine/yellow b/machine/yellow new file mode 100644 index 00000000000..d8e7421f9b1 --- /dev/null +++ b/machine/yellow @@ -0,0 +1,14 @@ +ARG BUILD_VERSION +FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION + +RUN apk --no-cache add \ + raspberrypi \ + raspberrypi-libs \ + usbutils + +## +# Set symlinks for raspberry pi binaries. +RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ + && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ + && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ + && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv diff --git a/mypy.ini b/mypy.ini index e0c512782fb..2b657041865 100644 --- a/mypy.ini +++ b/mypy.ini @@ -676,6 +676,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.emulated_hue.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.esphome.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -709,6 +720,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.fan.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fastdotcom.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1006,6 +1028,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homekit.type_locks] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homekit.type_triggers] check_untyped_defs = true disallow_incomplete_defs = true @@ -1160,17 +1193,6 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.ialarm_xr.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -no_implicit_optional = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.image_processing.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1919,6 +1941,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sensibo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sensor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2228,6 +2261,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trafikverket_ferry.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.trafikverket_train.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2712,15 +2756,6 @@ ignore_errors = true [mypy-homeassistant.components.lovelace.websocket] ignore_errors = true -[mypy-homeassistant.components.lutron_caseta] -ignore_errors = true - -[mypy-homeassistant.components.lutron_caseta.device_trigger] -ignore_errors = true - -[mypy-homeassistant.components.lutron_caseta.switch] -ignore_errors = true - [mypy-homeassistant.components.lyric.climate] ignore_errors = true @@ -2781,27 +2816,12 @@ ignore_errors = true [mypy-homeassistant.components.onvif.binary_sensor] ignore_errors = true -[mypy-homeassistant.components.onvif.button] -ignore_errors = true - [mypy-homeassistant.components.onvif.camera] ignore_errors = true -[mypy-homeassistant.components.onvif.config_flow] -ignore_errors = true - [mypy-homeassistant.components.onvif.device] ignore_errors = true -[mypy-homeassistant.components.onvif.event] -ignore_errors = true - -[mypy-homeassistant.components.onvif.models] -ignore_errors = true - -[mypy-homeassistant.components.onvif.parsers] -ignore_errors = true - [mypy-homeassistant.components.onvif.sensor] ignore_errors = true @@ -2907,12 +2927,6 @@ ignore_errors = true [mypy-homeassistant.components.unifi.unifi_entity_base] ignore_errors = true -[mypy-homeassistant.components.vizio.config_flow] -ignore_errors = true - -[mypy-homeassistant.components.vizio.media_player] -ignore_errors = true - [mypy-homeassistant.components.withings] ignore_errors = true @@ -2943,18 +2957,6 @@ ignore_errors = true [mypy-homeassistant.components.xbox.sensor] ignore_errors = true -[mypy-homeassistant.components.xiaomi_aqara] -ignore_errors = true - -[mypy-homeassistant.components.xiaomi_aqara.binary_sensor] -ignore_errors = true - -[mypy-homeassistant.components.xiaomi_aqara.lock] -ignore_errors = true - -[mypy-homeassistant.components.xiaomi_aqara.sensor] -ignore_errors = true - [mypy-homeassistant.components.xiaomi_miio] ignore_errors = true @@ -2984,120 +2986,3 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.switch] ignore_errors = true - -[mypy-homeassistant.components.zha.alarm_control_panel] -ignore_errors = true - -[mypy-homeassistant.components.zha.api] -ignore_errors = true - -[mypy-homeassistant.components.zha.binary_sensor] -ignore_errors = true - -[mypy-homeassistant.components.zha.button] -ignore_errors = true - -[mypy-homeassistant.components.zha.climate] -ignore_errors = true - -[mypy-homeassistant.components.zha.config_flow] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.base] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.closures] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.general] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.homeautomation] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.hvac] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.lighting] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.lightlink] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.manufacturerspecific] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.measurement] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.protocol] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.security] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.smartenergy] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.decorators] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.device] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.discovery] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.gateway] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.group] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.helpers] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.registries] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.store] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.typing] -ignore_errors = true - -[mypy-homeassistant.components.zha.cover] -ignore_errors = true - -[mypy-homeassistant.components.zha.device_action] -ignore_errors = true - -[mypy-homeassistant.components.zha.device_tracker] -ignore_errors = true - -[mypy-homeassistant.components.zha.entity] -ignore_errors = true - -[mypy-homeassistant.components.zha.fan] -ignore_errors = true - -[mypy-homeassistant.components.zha.light] -ignore_errors = true - -[mypy-homeassistant.components.zha.lock] -ignore_errors = true - -[mypy-homeassistant.components.zha.select] -ignore_errors = true - -[mypy-homeassistant.components.zha.sensor] -ignore_errors = true - -[mypy-homeassistant.components.zha.siren] -ignore_errors = true - -[mypy-homeassistant.components.zha.switch] -ignore_errors = true diff --git a/pylint/plugins/hass_constructor.py b/pylint/plugins/hass_constructor.py index f0f23ef4c95..23496b68de3 100644 --- a/pylint/plugins/hass_constructor.py +++ b/pylint/plugins/hass_constructor.py @@ -1,19 +1,18 @@ """Plugin for constructor definitions.""" -from astroid import Const, FunctionDef +from __future__ import annotations + +from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter class HassConstructorFormatChecker(BaseChecker): # type: ignore[misc] """Checker for __init__ definitions.""" - __implements__ = IAstroidChecker - name = "hass_constructor" priority = -1 msgs = { - "W0006": ( + "W7411": ( '__init__ should have explicit return type "None"', "hass-constructor-return", "Used when __init__ has all arguments typed " @@ -22,7 +21,7 @@ class HassConstructorFormatChecker(BaseChecker): # type: ignore[misc] } options = () - def visit_functiondef(self, node: FunctionDef) -> None: + def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Called when a FunctionDef node is visited.""" if not node.is_method() or node.name != "__init__": return @@ -43,7 +42,7 @@ class HassConstructorFormatChecker(BaseChecker): # type: ignore[misc] return # Check that return type is specified and it is "None". - if not isinstance(node.returns, Const) or node.returns.value is not None: + if not isinstance(node.returns, nodes.Const) or node.returns.value is not None: self.add_message("hass-constructor-return", node=node) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index e230c27b4ee..5f4f641cbae 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -4,27 +4,53 @@ from __future__ import annotations from dataclasses import dataclass import re -import astroid +from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter from homeassistant.const import Platform +DEVICE_CLASS = object() UNDEFINED = object() +_PLATFORMS: set[str] = {platform.value for platform in Platform} + @dataclass class TypeHintMatch: """Class for pattern matching.""" - module_filter: re.Pattern function_name: str - arg_types: dict[int, str] - return_type: list[str] | str | None + return_type: list[str] | str | None | object + arg_types: dict[int, str] | None = None + """arg_types is for positional arguments""" + named_arg_types: dict[str, str] | None = None + """named_arg_types is for named or keyword arguments""" + kwargs_type: str | None = None + """kwargs_type is for the special case `**kwargs`""" + check_return_type_inheritance: bool = False + has_async_counterpart: bool = False + + def need_to_check_function(self, node: nodes.FunctionDef) -> bool: + """Confirm if function should be checked.""" + return ( + self.function_name == node.name + or self.has_async_counterpart + and node.name == f"async_{self.function_name}" + or self.function_name.endswith("*") + and node.name.startswith(self.function_name[:-1]) + ) -_TYPE_HINT_MATCHERS: dict[str, re.Pattern] = { +@dataclass +class ClassTypeHintMatch: + """Class for pattern matching.""" + + base_class: str + matches: list[TypeHintMatch] + + +_TYPE_HINT_MATCHERS: dict[str, re.Pattern[str]] = { # a_or_b matches items such as "DiscoveryInfoType | None" "a_or_b": re.compile(r"^(\w+) \| (\w+)$"), # x_of_y matches items such as "Awaitable[None]" @@ -35,395 +61,1040 @@ _TYPE_HINT_MATCHERS: dict[str, re.Pattern] = { "x_of_y_of_z_comma_a": re.compile(r"^(\w+)\[(\w+)\[(.*?]*), (.*?]*)\]\]$"), } -_MODULE_FILTERS: dict[str, re.Pattern] = { - # init matches only in the package root (__init__.py) - "init": re.compile(r"^homeassistant\.components\.\w+$"), - # any_platform matches any platform in the package root ({platform}.py) - "any_platform": re.compile( - f"^homeassistant\\.components\\.\\w+\\.({'|'.join([platform.value for platform in Platform])})$" - ), - # application_credentials matches only in the package root (application_credentials.py) - "application_credentials": re.compile( - r"^homeassistant\.components\.\w+\.(application_credentials)$" - ), - # backup matches only in the package root (backup.py) - "backup": re.compile(r"^homeassistant\.components\.\w+\.(backup)$"), - # cast matches only in the package root (cast.py) - "cast": re.compile(r"^homeassistant\.components\.\w+\.(cast)$"), - # config_flow matches only in the package root (config_flow.py) - "config_flow": re.compile(r"^homeassistant\.components\.\w+\.(config_flow)$"), - # device_action matches only in the package root (device_action.py) - "device_action": re.compile(r"^homeassistant\.components\.\w+\.(device_action)$"), - # device_condition matches only in the package root (device_condition.py) - "device_condition": re.compile( - r"^homeassistant\.components\.\w+\.(device_condition)$" - ), - # device_tracker matches only in the package root (device_tracker.py) - "device_tracker": re.compile(r"^homeassistant\.components\.\w+\.(device_tracker)$"), - # device_trigger matches only in the package root (device_trigger.py) - "device_trigger": re.compile(r"^homeassistant\.components\.\w+\.(device_trigger)$"), - # diagnostics matches only in the package root (diagnostics.py) - "diagnostics": re.compile(r"^homeassistant\.components\.\w+\.(diagnostics)$"), +_MODULE_REGEX: re.Pattern[str] = re.compile(r"^homeassistant\.components\.\w+(\.\w+)?$") + +_FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { + "__init__": [ + TypeHintMatch( + function_name="setup", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="bool", + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="async_setup_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_remove_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_unload_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_migrate_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_remove_config_entry_device", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + 2: "DeviceEntry", + }, + return_type="bool", + ), + ], + "__any_platform__": [ + TypeHintMatch( + function_name="setup_platform", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "AddEntitiesCallback", + 3: "DiscoveryInfoType | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="async_setup_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + 2: "AddEntitiesCallback", + }, + return_type=None, + ), + ], + "application_credentials": [ + TypeHintMatch( + function_name="async_get_auth_implementation", + arg_types={ + 0: "HomeAssistant", + 1: "str", + 2: "ClientCredential", + }, + return_type="AbstractOAuth2Implementation", + ), + TypeHintMatch( + function_name="async_get_authorization_server", + arg_types={ + 0: "HomeAssistant", + }, + return_type="AuthorizationServer", + ), + ], + "backup": [ + TypeHintMatch( + function_name="async_pre_backup", + arg_types={ + 0: "HomeAssistant", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_post_backup", + arg_types={ + 0: "HomeAssistant", + }, + return_type=None, + ), + ], + "cast": [ + TypeHintMatch( + function_name="async_get_media_browser_root_object", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type="list[BrowseMedia]", + ), + TypeHintMatch( + function_name="async_browse_media", + arg_types={ + 0: "HomeAssistant", + 1: "str", + 2: "str", + 3: "str", + }, + return_type=["BrowseMedia", "BrowseMedia | None"], + ), + TypeHintMatch( + function_name="async_play_media", + arg_types={ + 0: "HomeAssistant", + 1: "str", + 2: "Chromecast", + 3: "str", + 4: "str", + }, + return_type="bool", + ), + ], + "config_flow": [ + TypeHintMatch( + function_name="_async_has_devices", + arg_types={ + 0: "HomeAssistant", + }, + return_type="bool", + ), + ], + "device_action": [ + TypeHintMatch( + function_name="async_validate_action_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConfigType", + ), + TypeHintMatch( + function_name="async_call_action_from_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "TemplateVarsType", + 3: "Context | None", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_get_action_capabilities", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="dict[str, Schema]", + ), + TypeHintMatch( + function_name="async_get_actions", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + ), + ], + "device_condition": [ + TypeHintMatch( + function_name="async_validate_condition_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConfigType", + ), + TypeHintMatch( + function_name="async_condition_from_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConditionCheckerType", + ), + TypeHintMatch( + function_name="async_get_condition_capabilities", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="dict[str, Schema]", + ), + TypeHintMatch( + function_name="async_get_conditions", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + ), + ], + "device_tracker": [ + TypeHintMatch( + function_name="setup_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "Callable[..., None]", + 3: "DiscoveryInfoType | None", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_setup_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "Callable[..., Awaitable[None]]", + 3: "DiscoveryInfoType | None", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="get_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type=["DeviceScanner", "DeviceScanner | None"], + has_async_counterpart=True, + ), + ], + "device_trigger": [ + TypeHintMatch( + function_name="async_validate_condition_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConfigType", + ), + TypeHintMatch( + function_name="async_attach_trigger", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "AutomationActionType", + 3: "AutomationTriggerInfo", + }, + return_type="CALLBACK_TYPE", + ), + TypeHintMatch( + function_name="async_get_trigger_capabilities", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="dict[str, Schema]", + ), + TypeHintMatch( + function_name="async_get_triggers", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + ), + ], + "diagnostics": [ + TypeHintMatch( + function_name="async_get_config_entry_diagnostics", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type=UNDEFINED, + ), + TypeHintMatch( + function_name="async_get_device_diagnostics", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + 2: "DeviceEntry", + }, + return_type=UNDEFINED, + ), + ], } -_METHOD_MATCH: list[TypeHintMatch] = [ +_CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { + "config_flow": [ + ClassTypeHintMatch( + base_class="FlowHandler", + matches=[ + TypeHintMatch( + function_name="async_step_*", + arg_types={}, + return_type="FlowResult", + ), + ], + ), + ClassTypeHintMatch( + base_class="ConfigFlow", + matches=[ + TypeHintMatch( + function_name="async_get_options_flow", + arg_types={ + 0: "ConfigEntry", + }, + return_type="OptionsFlow", + check_return_type_inheritance=True, + ), + TypeHintMatch( + function_name="async_step_dhcp", + arg_types={ + 1: "DhcpServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_hassio", + arg_types={ + 1: "HassioServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_homekit", + arg_types={ + 1: "ZeroconfServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_mqtt", + arg_types={ + 1: "MqttServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_reauth", + arg_types={ + 1: "Mapping[str, Any]", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_ssdp", + arg_types={ + 1: "SsdpServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_usb", + arg_types={ + 1: "UsbServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_zeroconf", + arg_types={ + 1: "ZeroconfServiceInfo", + }, + return_type="FlowResult", + ), + ], + ), + ], +} +# Overriding properties and functions are normally checked by mypy, and will only +# be checked by pylint when --ignore-missing-annotations is False +_ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="setup", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, + function_name="should_poll", return_type="bool", ), TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_setup", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, + function_name="unique_id", + return_type=["str", None], + ), + TypeHintMatch( + function_name="name", + return_type=["str", None], + ), + TypeHintMatch( + function_name="state", + return_type=["StateType", None, "str", "int", "float"], + ), + TypeHintMatch( + function_name="capability_attributes", + return_type=["Mapping[str, Any]", None], + ), + TypeHintMatch( + function_name="state_attributes", + return_type=["dict[str, Any]", None], + ), + TypeHintMatch( + function_name="device_state_attributes", + return_type=["Mapping[str, Any]", None], + ), + TypeHintMatch( + function_name="extra_state_attributes", + return_type=["Mapping[str, Any]", None], + ), + TypeHintMatch( + function_name="device_info", + return_type=["DeviceInfo", None], + ), + TypeHintMatch( + function_name="device_class", + return_type=[DEVICE_CLASS, "str", None], + ), + TypeHintMatch( + function_name="unit_of_measurement", + return_type=["str", None], + ), + TypeHintMatch( + function_name="icon", + return_type=["str", None], + ), + TypeHintMatch( + function_name="entity_picture", + return_type=["str", None], + ), + TypeHintMatch( + function_name="available", return_type="bool", ), TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_setup_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, + function_name="assumed_state", return_type="bool", ), TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_remove_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, + function_name="force_update", + return_type="bool", + ), + TypeHintMatch( + function_name="supported_features", + return_type=["int", None], + ), + TypeHintMatch( + function_name="context_recent_time", + return_type="timedelta", + ), + TypeHintMatch( + function_name="entity_registry_enabled_default", + return_type="bool", + ), + TypeHintMatch( + function_name="entity_registry_visible_default", + return_type="bool", + ), + TypeHintMatch( + function_name="attribution", + return_type=["str", None], + ), + TypeHintMatch( + function_name="entity_category", + return_type=["EntityCategory", None], + ), + TypeHintMatch( + function_name="async_removed_from_registry", return_type=None, ), TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_unload_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_migrate_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["any_platform"], - function_name="setup_platform", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "AddEntitiesCallback", - 3: "DiscoveryInfoType | None", - }, + function_name="async_added_to_hass", return_type=None, ), TypeHintMatch( - module_filter=_MODULE_FILTERS["any_platform"], - function_name="async_setup_platform", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "AddEntitiesCallback", - 3: "DiscoveryInfoType | None", - }, + function_name="async_will_remove_from_hass", return_type=None, ), TypeHintMatch( - module_filter=_MODULE_FILTERS["any_platform"], - function_name="async_setup_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - 2: "AddEntitiesCallback", - }, + function_name="async_registry_entry_updated", return_type=None, ), TypeHintMatch( - module_filter=_MODULE_FILTERS["application_credentials"], - function_name="async_get_auth_implementation", - arg_types={ - 0: "HomeAssistant", - 1: "str", - 2: "ClientCredential", - }, - return_type="AbstractOAuth2Implementation", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["application_credentials"], - function_name="async_get_authorization_server", - arg_types={ - 0: "HomeAssistant", - }, - return_type="AuthorizationServer", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["backup"], - function_name="async_pre_backup", - arg_types={ - 0: "HomeAssistant", - }, + function_name="update", return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["backup"], - function_name="async_post_backup", - arg_types={ - 0: "HomeAssistant", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["cast"], - function_name="async_get_media_browser_root_object", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type="list[BrowseMedia]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["cast"], - function_name="async_browse_media", - arg_types={ - 0: "HomeAssistant", - 1: "str", - 2: "str", - 3: "str", - }, - return_type=["BrowseMedia", "BrowseMedia | None"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["cast"], - function_name="async_play_media", - arg_types={ - 0: "HomeAssistant", - 1: "str", - 2: "Chromecast", - 3: "str", - 4: "str", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["config_flow"], - function_name="_async_has_devices", - arg_types={ - 0: "HomeAssistant", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_validate_action_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConfigType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_call_action_from_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "TemplateVarsType", - 3: "Context | None", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_get_action_capabilities", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="dict[str, Schema]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_get_actions", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_validate_condition_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConfigType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_condition_from_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConditionCheckerType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_get_condition_capabilities", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="dict[str, Schema]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_get_conditions", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="setup_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "Callable[..., None]", - 3: "DiscoveryInfoType | None", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="async_setup_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "Callable[..., Awaitable[None]]", - 3: "DiscoveryInfoType | None", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="get_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type=["DeviceScanner", "DeviceScanner | None"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="async_get_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type=["DeviceScanner", "DeviceScanner | None"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_validate_condition_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConfigType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_attach_trigger", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "AutomationActionType", - 3: "AutomationTriggerInfo", - }, - return_type="CALLBACK_TYPE", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_get_trigger_capabilities", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="dict[str, Schema]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_get_triggers", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["diagnostics"], - function_name="async_get_config_entry_diagnostics", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type=UNDEFINED, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["diagnostics"], - function_name="async_get_device_diagnostics", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - 2: "DeviceEntry", - }, - return_type=UNDEFINED, + has_async_counterpart=True, ), ] +_TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ + TypeHintMatch( + function_name="is_on", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="turn_on", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="turn_off", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="toggle", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), +] +_INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { + "alarm_control_panel": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="AlarmControlPanelEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["str", None], + ), + TypeHintMatch( + function_name="code_format", + return_type=["CodeFormat", None], + ), + TypeHintMatch( + function_name="changed_by", + return_type=["str", None], + ), + TypeHintMatch( + function_name="code_arm_required", + return_type="bool", + ), + TypeHintMatch( + function_name="supported_features", + return_type="int", + ), + TypeHintMatch( + function_name="alarm_disarm", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_home", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_away", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_night", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_vacation", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_trigger", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_custom_bypass", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], + "binary_sensor": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="BinarySensorEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["BinarySensorDeviceClass", "str", None], + ), + TypeHintMatch( + function_name="is_on", + return_type=["bool", None], + ), + ], + ), + ], + "button": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="ButtonEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["ButtonDeviceClass", "str", None], + ), + TypeHintMatch( + function_name="press", + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], + "cover": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="CoverEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["CoverDeviceClass", "str", None], + ), + TypeHintMatch( + function_name="current_cover_position", + return_type=["int", None], + ), + TypeHintMatch( + function_name="current_cover_tilt_position", + return_type=["int", None], + ), + TypeHintMatch( + function_name="is_opening", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_closing", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_closed", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="open_cover", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="close_cover", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="toggle", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="set_cover_position", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="stop_cover", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="open_cover_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="close_cover_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="set_cover_tilt_position", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="stop_cover_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="toggle_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], + "fan": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="ToggleEntity", + matches=_TOGGLE_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="FanEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["str", None], + ), + TypeHintMatch( + function_name="percentage", + return_type=["int", None], + ), + TypeHintMatch( + function_name="speed_count", + return_type="int", + ), + TypeHintMatch( + function_name="percentage_step", + return_type="float", + ), + TypeHintMatch( + function_name="current_direction", + return_type=["str", None], + ), + TypeHintMatch( + function_name="oscillating", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="preset_mode", + return_type=["str", None], + ), + TypeHintMatch( + function_name="preset_modes", + return_type=["list[str]", None], + ), + TypeHintMatch( + function_name="set_percentage", + arg_types={1: "int"}, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="set_preset_mode", + arg_types={1: "str"}, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="set_direction", + arg_types={1: "str"}, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="turn_on", + named_arg_types={ + "percentage": "int | None", + "preset_mode": "str | None", + }, + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="oscillate", + arg_types={1: "bool"}, + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], + "light": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="ToggleEntity", + matches=_TOGGLE_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="LightEntity", + matches=[ + TypeHintMatch( + function_name="brightness", + return_type=["int", None], + ), + TypeHintMatch( + function_name="color_mode", + return_type=["ColorMode", "str", None], + ), + TypeHintMatch( + function_name="hs_color", + return_type=["tuple[float, float]", None], + ), + TypeHintMatch( + function_name="xy_color", + return_type=["tuple[float, float]", None], + ), + TypeHintMatch( + function_name="rgb_color", + return_type=["tuple[int, int, int]", None], + ), + TypeHintMatch( + function_name="rgbw_color", + return_type=["tuple[int, int, int, int]", None], + ), + TypeHintMatch( + function_name="rgbww_color", + return_type=["tuple[int, int, int, int, int]", None], + ), + TypeHintMatch( + function_name="color_temp", + return_type=["int", None], + ), + TypeHintMatch( + function_name="min_mireds", + return_type="int", + ), + TypeHintMatch( + function_name="max_mireds", + return_type="int", + ), + TypeHintMatch( + function_name="white_value", + return_type=["int", None], + ), + TypeHintMatch( + function_name="effect_list", + return_type=["list[str]", None], + ), + TypeHintMatch( + function_name="effect", + return_type=["str", None], + ), + TypeHintMatch( + function_name="capability_attributes", + return_type=["dict[str, Any]", None], + ), + TypeHintMatch( + function_name="supported_color_modes", + return_type=["set[ColorMode]", "set[str]", None], + ), + TypeHintMatch( + function_name="supported_features", + return_type="int", + ), + TypeHintMatch( + function_name="turn_on", + named_arg_types={ + "brightness": "int | None", + "brightness_pct": "float | None", + "brightness_step": "int | None", + "brightness_step_pct": "float | None", + "color_name": "str | None", + "color_temp": "int | None", + "effect": "str | None", + "flash": "str | None", + "kelvin": "int | None", + "hs_color": "tuple[float, float] | None", + "rgb_color": "tuple[int, int, int] | None", + "rgbw_color": "tuple[int, int, int, int] | None", + "rgbww_color": "tuple[int, int, int, int, int] | None", + "transition": "float | None", + "xy_color": "tuple[float, float] | None", + "white": "int | None", + "white_value": "int | None", + }, + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], + "lock": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="LockEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["str", None], + ), + TypeHintMatch( + function_name="changed_by", + return_type=["str", None], + ), + TypeHintMatch( + function_name="code_format", + return_type=["str", None], + ), + TypeHintMatch( + function_name="is_locked", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_locking", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_unlocking", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_jammed", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="lock", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="unlock", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="open", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], +} -def _is_valid_type(expected_type: list[str] | str | None, node: astroid.NodeNG) -> bool: +def _is_valid_type( + expected_type: list[str] | str | None | object, + node: nodes.NodeNG, + in_return: bool = False, +) -> bool: """Check the argument node against the expected type.""" if expected_type is UNDEFINED: return True + # Special case for device_class + if expected_type == DEVICE_CLASS and in_return: + return ( + isinstance(node, nodes.Name) + and node.name.endswith("DeviceClass") + or isinstance(node, nodes.Attribute) + and node.attrname.endswith("DeviceClass") + ) + if isinstance(expected_type, list): for expected_type_item in expected_type: - if _is_valid_type(expected_type_item, node): + if _is_valid_type(expected_type_item, node, in_return): return True return False # Const occurs when the type is None if expected_type is None or expected_type == "None": - return isinstance(node, astroid.Const) and node.value is None + return isinstance(node, nodes.Const) and node.value is None + + assert isinstance(expected_type, str) # Const occurs when the type is an Ellipsis if expected_type == "...": - return isinstance(node, astroid.Const) and node.value == Ellipsis + return isinstance(node, nodes.Const) and node.value == Ellipsis # Special case for `xxx | yyy` if match := _TYPE_HINT_MATCHERS["a_or_b"].match(expected_type): return ( - isinstance(node, astroid.BinOp) + isinstance(node, nodes.BinOp) and _is_valid_type(match.group(1), node.left) and _is_valid_type(match.group(2), node.right) ) @@ -431,21 +1102,33 @@ def _is_valid_type(expected_type: list[str] | str | None, node: astroid.NodeNG) # Special case for xxx[yyy[zzz, aaa]]` if match := _TYPE_HINT_MATCHERS["x_of_y_of_z_comma_a"].match(expected_type): return ( - isinstance(node, astroid.Subscript) + isinstance(node, nodes.Subscript) and _is_valid_type(match.group(1), node.value) - and isinstance(subnode := node.slice, astroid.Subscript) + and isinstance(subnode := node.slice, nodes.Subscript) and _is_valid_type(match.group(2), subnode.value) - and isinstance(subnode.slice, astroid.Tuple) + and isinstance(subnode.slice, nodes.Tuple) and _is_valid_type(match.group(3), subnode.slice.elts[0]) and _is_valid_type(match.group(4), subnode.slice.elts[1]) ) # Special case for xxx[yyy, zzz]` if match := _TYPE_HINT_MATCHERS["x_of_y_comma_z"].match(expected_type): + # Handle special case of Mapping[xxx, Any] + if in_return and match.group(1) == "Mapping" and match.group(3) == "Any": + return ( + isinstance(node, nodes.Subscript) + and isinstance(node.value, nodes.Name) + # We accept dict when Mapping is needed + and node.value.name in ("Mapping", "dict") + and isinstance(node.slice, nodes.Tuple) + and _is_valid_type(match.group(2), node.slice.elts[0]) + # Ignore second item + # and _is_valid_type(match.group(3), node.slice.elts[1]) + ) return ( - isinstance(node, astroid.Subscript) + isinstance(node, nodes.Subscript) and _is_valid_type(match.group(1), node.value) - and isinstance(node.slice, astroid.Tuple) + and isinstance(node.slice, nodes.Tuple) and _is_valid_type(match.group(2), node.slice.elts[0]) and _is_valid_type(match.group(3), node.slice.elts[1]) ) @@ -453,22 +1136,48 @@ def _is_valid_type(expected_type: list[str] | str | None, node: astroid.NodeNG) # Special case for xxx[yyy]` if match := _TYPE_HINT_MATCHERS["x_of_y"].match(expected_type): return ( - isinstance(node, astroid.Subscript) + isinstance(node, nodes.Subscript) and _is_valid_type(match.group(1), node.value) and _is_valid_type(match.group(2), node.slice) ) # Name occurs when a namespace is not used, eg. "HomeAssistant" - if isinstance(node, astroid.Name) and node.name == expected_type: + if isinstance(node, nodes.Name) and node.name == expected_type: return True # Attribute occurs when a namespace is used, eg. "core.HomeAssistant" - return isinstance(node, astroid.Attribute) and node.attrname == expected_type + return isinstance(node, nodes.Attribute) and node.attrname == expected_type -def _get_all_annotations(node: astroid.FunctionDef) -> list[astroid.NodeNG | None]: +def _is_valid_return_type(match: TypeHintMatch, node: nodes.NodeNG) -> bool: + if _is_valid_type(match.return_type, node, True): + return True + + if isinstance(node, nodes.BinOp): + return _is_valid_return_type(match, node.left) and _is_valid_return_type( + match, node.right + ) + + if ( + match.check_return_type_inheritance + and isinstance(match.return_type, str) + and isinstance(node, nodes.Name) + ): + ancestor: nodes.ClassDef + for infer_node in node.infer(): + if isinstance(infer_node, nodes.ClassDef): + if infer_node.name == match.return_type: + return True + for ancestor in infer_node.ancestors(): + if ancestor.name == match.return_type: + return True + + return False + + +def _get_all_annotations(node: nodes.FunctionDef) -> list[nodes.NodeNG | None]: args = node.args - annotations: list[astroid.NodeNG | None] = ( + annotations: list[nodes.NodeNG | None] = ( args.posonlyargs_annotations + args.annotations + args.kwonlyargs_annotations ) if args.vararg is not None: @@ -478,8 +1187,23 @@ def _get_all_annotations(node: astroid.FunctionDef) -> list[astroid.NodeNG | Non return annotations +def _get_named_annotation( + node: nodes.FunctionDef, key: str +) -> tuple[nodes.NodeNG, nodes.NodeNG] | tuple[None, None]: + args = node.args + for index, arg_node in enumerate(args.args): + if key == arg_node.name: + return arg_node, args.annotations[index] + + for index, arg_node in enumerate(args.kwonlyargs): + if key == arg_node.name: + return arg_node, args.kwonlyargs_annotations[index] + + return None, None + + def _has_valid_annotations( - annotations: list[astroid.NodeNG | None], + annotations: list[nodes.NodeNG | None], ) -> bool: for annotation in annotations: if annotation is not None: @@ -487,78 +1211,145 @@ def _has_valid_annotations( return False +def _get_module_platform(module_name: str) -> str | None: + """Called when a Module node is visited.""" + if not (module_match := _MODULE_REGEX.match(module_name)): + # Ensure `homeassistant.components.` + # Or `homeassistant.components..` + return None + + platform = module_match.groups()[0] + return platform.lstrip(".") if platform else "__init__" + + class HassTypeHintChecker(BaseChecker): # type: ignore[misc] """Checker for setup type hints.""" - __implements__ = IAstroidChecker - name = "hass_enforce_type_hints" priority = -1 msgs = { - "W0020": ( - "Argument %d should be of type %s", + "W7431": ( + "Argument %s should be of type %s", "hass-argument-type", "Used when method argument type is incorrect", ), - "W0021": ( + "W7432": ( "Return type should be %s", "hass-return-type", "Used when method return type is incorrect", ), } - options = () + options = ( + ( + "ignore-missing-annotations", + { + "default": True, + "type": "yn", + "metavar": "", + "help": "Set to ``no`` if you wish to check functions that do not " + "have any type hints.", + }, + ), + ) def __init__(self, linter: PyLinter | None = None) -> None: super().__init__(linter) - self.current_package: str | None = None - self.module: str | None = None + self._function_matchers: list[TypeHintMatch] = [] + self._class_matchers: list[ClassTypeHintMatch] = [] - def visit_module(self, node: astroid.Module) -> None: + def visit_module(self, node: nodes.Module) -> None: """Called when a Module node is visited.""" - self.module = node.name - if node.package: - self.current_package = node.name - else: - # Strip name of the current module - self.current_package = node.name[: node.name.rfind(".")] + self._function_matchers = [] + self._class_matchers = [] - def visit_functiondef(self, node: astroid.FunctionDef) -> None: - """Called when a FunctionDef node is visited.""" - for match in _METHOD_MATCH: - self._visit_functiondef(node, match) + if (module_platform := _get_module_platform(node.name)) is None: + return - def visit_asyncfunctiondef(self, node: astroid.AsyncFunctionDef) -> None: - """Called when an AsyncFunctionDef node is visited.""" - for match in _METHOD_MATCH: - self._visit_functiondef(node, match) + if module_platform in _PLATFORMS: + self._function_matchers.extend(_FUNCTION_MATCH["__any_platform__"]) - def _visit_functiondef( - self, node: astroid.FunctionDef, match: TypeHintMatch + if function_matches := _FUNCTION_MATCH.get(module_platform): + self._function_matchers.extend(function_matches) + + if class_matches := _CLASS_MATCH.get(module_platform): + self._class_matchers.extend(class_matches) + + if not self.linter.config.ignore_missing_annotations and ( + property_matches := _INHERITANCE_MATCH.get(module_platform) + ): + self._class_matchers.extend(property_matches) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Called when a ClassDef node is visited.""" + ancestor: nodes.ClassDef + for ancestor in node.ancestors(): + for class_matches in self._class_matchers: + if ancestor.name == class_matches.base_class: + self._visit_class_functions(node, class_matches.matches) + + def _visit_class_functions( + self, node: nodes.ClassDef, matches: list[TypeHintMatch] ) -> None: - if node.name != match.function_name: - return - if node.is_method(): - return - if not match.module_filter.match(self.module): - return + for match in matches: + for function_node in node.mymethods(): + if match.need_to_check_function(function_node): + self._check_function(function_node, match) + def visit_functiondef(self, node: nodes.FunctionDef) -> None: + """Called when a FunctionDef node is visited.""" + for match in self._function_matchers: + if not match.need_to_check_function(node) or node.is_method(): + continue + self._check_function(node, match) + + visit_asyncfunctiondef = visit_functiondef + + def _check_function(self, node: nodes.FunctionDef, match: TypeHintMatch) -> None: # Check that at least one argument is annotated. annotations = _get_all_annotations(node) - if node.returns is None and not _has_valid_annotations(annotations): + if ( + self.linter.config.ignore_missing_annotations + and node.returns is None + and not _has_valid_annotations(annotations) + ): return - # Check that all arguments are correctly annotated. - for key, expected_type in match.arg_types.items(): - if not _is_valid_type(expected_type, annotations[key]): - self.add_message( - "hass-argument-type", - node=node.args.args[key], - args=(key + 1, expected_type), - ) + # Check that all positional arguments are correctly annotated. + if match.arg_types: + for key, expected_type in match.arg_types.items(): + if not _is_valid_type(expected_type, annotations[key]): + self.add_message( + "hass-argument-type", + node=node.args.args[key], + args=(key + 1, expected_type), + ) + + # Check that all keyword arguments are correctly annotated. + if match.named_arg_types is not None: + for arg_name, expected_type in match.named_arg_types.items(): + arg_node, annotation = _get_named_annotation(node, arg_name) + if arg_node and not _is_valid_type(expected_type, annotation): + self.add_message( + "hass-argument-type", + node=arg_node, + args=(arg_name, expected_type), + ) + + # Check that kwargs is correctly annotated. + if match.kwargs_type and not _is_valid_type( + match.kwargs_type, node.args.kwargannotation + ): + self.add_message( + "hass-argument-type", + node=node, + args=(node.args.kwarg, match.kwargs_type), + ) # Check the return type. - if not _is_valid_type(return_type := match.return_type, node.returns): - self.add_message("hass-return-type", node=node, args=return_type or "None") + if not _is_valid_return_type(match, node.returns): + self.add_message( + "hass-return-type", node=node, args=match.return_type or "None" + ) def register(linter: PyLinter) -> None: diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index c6f6c25c7b6..31fbe8f498e 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -4,9 +4,8 @@ from __future__ import annotations from dataclasses import dataclass import re -from astroid import Import, ImportFrom, Module +from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter @@ -14,7 +13,7 @@ from pylint.lint import PyLinter class ObsoleteImportMatch: """Class for pattern matching.""" - constant: re.Pattern + constant: re.Pattern[str] reason: str @@ -221,6 +220,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { constant=re.compile(r"^SOURCE_(\w*)$"), ), ], + "homeassistant.data_entry_flow": [ + ObsoleteImportMatch( + reason="replaced by FlowResultType enum", + constant=re.compile(r"^RESULT_TYPE_(\w*)$"), + ), + ], "homeassistant.helpers.device_registry": [ ObsoleteImportMatch( reason="replaced by DeviceEntryDisabler enum", @@ -233,17 +238,15 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] """Checker for imports.""" - __implements__ = IAstroidChecker - name = "hass_imports" priority = -1 msgs = { - "W0011": ( + "W7421": ( "Relative import should be used", "hass-relative-import", "Used when absolute import should be replaced with relative import", ), - "W0012": ( + "W7422": ( "%s is deprecated, %s", "hass-deprecated-import", "Used when import is deprecated", @@ -255,7 +258,7 @@ class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] super().__init__(linter) self.current_package: str | None = None - def visit_module(self, node: Module) -> None: + def visit_module(self, node: nodes.Module) -> None: """Called when a Module node is visited.""" if node.package: self.current_package = node.name @@ -263,13 +266,13 @@ class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] # Strip name of the current module self.current_package = node.name[: node.name.rfind(".")] - def visit_import(self, node: Import) -> None: + def visit_import(self, node: nodes.Import) -> None: """Called when a Import node is visited.""" for module, _alias in node.names: if module.startswith(f"{self.current_package}."): self.add_message("hass-relative-import", node=node) - def visit_importfrom(self, node: ImportFrom) -> None: + def visit_importfrom(self, node: nodes.ImportFrom) -> None: """Called when a ImportFrom node is visited.""" if node.level is not None: return diff --git a/pylint/plugins/hass_logger.py b/pylint/plugins/hass_logger.py index 0ca57b8da19..0135720a792 100644 --- a/pylint/plugins/hass_logger.py +++ b/pylint/plugins/hass_logger.py @@ -1,7 +1,8 @@ """Plugin for logger invocations.""" -import astroid +from __future__ import annotations + +from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter LOGGER_NAMES = ("LOGGER", "_LOGGER") @@ -11,17 +12,15 @@ LOG_LEVEL_ALLOWED_LOWER_START = ("debug",) class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] """Checker for logger invocations.""" - __implements__ = IAstroidChecker - name = "hass_logger" priority = -1 msgs = { - "W0001": ( + "W7401": ( "User visible logger messages must not end with a period", "hass-logger-period", "Periods are not permitted at the end of logger messages", ), - "W0002": ( + "W7402": ( "User visible logger messages must start with a capital letter or downgrade to debug", "hass-logger-capital", "All logger messages must start with a capital letter", @@ -29,10 +28,10 @@ class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] } options = () - def visit_call(self, node: astroid.Call) -> None: + def visit_call(self, node: nodes.Call) -> None: """Called when a Call node is visited.""" - if not isinstance(node.func, astroid.Attribute) or not isinstance( - node.func.expr, astroid.Name + if not isinstance(node.func, nodes.Attribute) or not isinstance( + node.func.expr, nodes.Name ): return @@ -44,7 +43,7 @@ class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] first_arg = node.args[0] - if not isinstance(first_arg, astroid.Const) or not first_arg.value: + if not isinstance(first_arg, nodes.Const) or not first_arg.value: return log_message = first_arg.value diff --git a/pyproject.toml b/pyproject.toml index e0db5324d02..b4994d55edb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" +version = "2022.7.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" authors = [ - {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} + {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} ] keywords = ["home", "automation"] classifiers = [ @@ -21,7 +22,35 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Topic :: Home Automation", ] -dynamic = ["version", "requires-python", "dependencies"] +requires-python = ">=3.9.0" +dependencies = [ + "aiohttp==3.8.1", + "astral==2.2", + "async_timeout==4.0.2", + "attrs==21.2.0", + "atomicwrites==1.4.0", + "awesomeversion==22.6.0", + "bcrypt==3.1.7", + "certifi>=2021.5.30", + "ciso8601==2.2.0", + # When bumping httpx, please check the version pins of + # httpcore, anyio, and h11 in gen_requirements_all + "httpx==0.23.0", + "ifaddr==0.1.7", + "jinja2==3.1.2", + "PyJWT==2.4.0", + # PyJWT has loose dependency. We want the latest one. + "cryptography==36.0.2", + "orjson==3.7.5", + "pip>=21.0,<22.2", + "python-slugify==4.0.1", + "pyyaml==6.0", + "requests==2.28.1", + "typing-extensions>=3.10.0.2,<5.0", + "voluptuous==0.13.1", + "voluptuous-serialize==2.5.0", + "yarl==1.7.2", +] [project.urls] "Source Code" = "https://github.com/home-assistant/core" @@ -59,7 +88,7 @@ forced_separate = [ ] combine_as_imports = true -[tool.pylint.MASTER] +[tool.pylint.MAIN] py-version = "3.9" ignore = [ "tests", @@ -91,6 +120,7 @@ extension-pkg-allow-list = [ "av.audio.stream", "av.stream", "ciso8601", + "orjson", "cv2", ] @@ -124,7 +154,6 @@ good-names = [ # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # consider-using-f-string - str.format sometimes more readable -# no-self-use - little added value with too many false-positives # --- # Enable once current issues are fixed: # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) @@ -151,7 +180,6 @@ disable = [ "unused-argument", "wrong-import-order", "consider-using-f-string", - "no-self-use", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", ] diff --git a/requirements.txt b/requirements.txt index fe2bf87ad25..98b148fa923 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ astral==2.2 async_timeout==4.0.2 attrs==21.2.0 atomicwrites==1.4.0 -awesomeversion==22.5.2 +awesomeversion==22.6.0 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 @@ -15,10 +15,11 @@ ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 cryptography==36.0.2 +orjson==3.7.5 pip>=21.0,<22.2 python-slugify==4.0.1 pyyaml==6.0 -requests==2.27.1 +requests==2.28.1 typing-extensions>=3.10.0.2,<5.0 voluptuous==0.13.1 voluptuous-serialize==2.5.0 diff --git a/requirements_all.txt b/requirements_all.txt index b52f43924d9..99be85cc418 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,11 +4,14 @@ # homeassistant.components.aemet AEMET-OpenData==0.2.1 +# homeassistant.components.aladdin_connect +AIOAladdinConnect==0.1.21 + # homeassistant.components.adax Adax-local==0.1.4 # homeassistant.components.homekit -HAP-python==4.4.0 +HAP-python==4.5.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -34,7 +37,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.13.3 +PySwitchbot==0.14.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -86,7 +89,7 @@ adguardhome==0.5.1 advantage_air==0.3.1 # homeassistant.components.frontier_silicon -afsapi==0.0.4 +afsapi==0.2.4 # homeassistant.components.agent_dvr agent-py==0.0.23 @@ -103,11 +106,14 @@ aio_geojson_geonetnz_volcano==0.6 # homeassistant.components.nsw_rural_fire_service_feed aio_geojson_nsw_rfs_incidents==0.4 +# homeassistant.components.usgs_earthquakes_feed +aio_geojson_usgs_earthquakes==0.1 + # homeassistant.components.gdacs aio_georss_gdacs==0.7 # homeassistant.components.airzone -aioairzone==0.4.4 +aioairzone==0.4.5 # homeassistant.components.ambient_station aioambient==2021.11.0 @@ -162,7 +168,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.17 +aiohomekit==0.7.20 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -172,7 +178,7 @@ aiohttp_cors==0.7.0 aiohue==4.4.2 # homeassistant.components.imap -aioimaplib==0.9.0 +aioimaplib==1.0.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 @@ -181,7 +187,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.7.1 +aiolifx==0.8.1 # homeassistant.components.lifx aiolifx_effects==0.2.2 @@ -220,10 +226,10 @@ aiopvapi==1.6.19 aiopvpc==3.0.0 # homeassistant.components.sonarr -aiopyarr==22.2.2 +aiopyarr==22.6.0 # homeassistant.components.qnap_qsw -aioqsw==0.0.8 +aioqsw==0.1.0 # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -240,11 +246,14 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.skybell +aioskybell==22.6.1 + # homeassistant.components.slimproto -aioslimproto==2.0.1 +aioslimproto==2.1.1 # homeassistant.components.steamist -aiosteamist==0.3.1 +aiosteamist==0.3.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -256,7 +265,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==32 +aiounifi==34 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -279,9 +288,6 @@ airthings_cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 -# homeassistant.components.aladdin_connect -aladdin_connect==0.4 - # homeassistant.components.alpha_vantage alpha_vantage==2.3.1 @@ -310,7 +316,7 @@ anthemav==1.2.0 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==0.9.8.3 +apprise==0.9.9 # homeassistant.components.aprs aprslib==0.7.0 @@ -336,7 +342,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.1 +async-upnp-client==0.31.2 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -351,11 +357,7 @@ atenpdu==0.3.2 auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone -aurorapy==0.2.6 - -# homeassistant.components.generic -# homeassistant.components.stream -av==9.2.0 +aurorapy==0.2.7 # homeassistant.components.avea # avea==1.5.1 @@ -391,16 +393,16 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.30.0 +bellows==0.31.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.4 +bimmer_connected==0.9.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 # homeassistant.components.blebox -blebox_uniapi==1.3.3 +blebox_uniapi==2.0.0 # homeassistant.components.blink blinkpy==0.19.0 @@ -433,7 +435,7 @@ bravia-tv==1.0.11 broadlink==0.18.2 # homeassistant.components.brother -brother==1.1.0 +brother==1.2.3 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -457,7 +459,7 @@ btsmarthub_devicelist==0.2.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==0.9.0 +caldav==0.9.1 # homeassistant.components.circuit circuit-webhook==1.0.1 @@ -638,8 +640,11 @@ feedparser==6.0.2 # homeassistant.components.fibaro fiblary3==0.1.8 +# homeassistant.components.file +file-read-backwards==2.0.0 + # homeassistant.components.fints -fints==1.0.1 +fints==3.1.0 # homeassistant.components.fitbit fitbit==0.3.1 @@ -689,7 +694,7 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.9.0 +gcal-sync==0.10.0 # homeassistant.components.geniushub geniushub-client==0.6.30 @@ -697,9 +702,6 @@ geniushub-client==0.6.30 # homeassistant.components.geocaching geocachingapi==0.2.1 -# homeassistant.components.usgs_earthquakes_feed -geojson_client==0.6 - # homeassistant.components.aprs geopy==2.1.0 @@ -738,10 +740,10 @@ goodwe==0.2.15 google-cloud-pubsub==2.11.0 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.11.0 +google-cloud-texttospeech==2.11.1 # homeassistant.components.nest -google-nest-sdm==1.8.0 +google-nest-sdm==2.0.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -756,7 +758,7 @@ gpiozero==1.6.2 gps3==0.33.3 # homeassistant.components.gree -greeclimate==1.1.1 +greeclimate==1.2.0 # homeassistant.components.greeneye_monitor greeneye_monitor==3.0.3 @@ -776,6 +778,10 @@ gstreamer-player==1.1.2 # homeassistant.components.profiler guppy3==3.1.2 +# homeassistant.components.generic +# homeassistant.components.stream +ha-av==10.0.0b4 + # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -819,16 +825,16 @@ hlk-sw16==0.0.9 hole==0.7.0 # homeassistant.components.workday -holidays==0.13 +holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220601.0 +home-assistant-frontend==20220706.0 # homeassistant.components.home_connect -homeconnect==0.7.0 +homeconnect==0.7.1 # homeassistant.components.homematicip_cloud -homematicip==1.0.2 +homematicip==1.0.3 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -888,7 +894,7 @@ influxdb==5.3.1 insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire -intellifire4py==1.0.2 +intellifire4py==2.0.1 # homeassistant.components.iotawatt iotawattpy==0.1.0 @@ -900,7 +906,7 @@ iperf3==0.1.11 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.7.2 +jellyfin-apiclient-python==1.8.1 # homeassistant.components.rest jsonpath==0.82 @@ -966,7 +972,7 @@ locationsharinglib==4.1.5 logi_circle==0.2.3 # homeassistant.components.london_underground -london-tube-status==0.2 +london-tube-status==0.5 # homeassistant.components.recorder lru-dict==1.1.7 @@ -1074,13 +1080,13 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.2.4 +nettigo-air-monitor==1.3.0 # homeassistant.components.neurio_energy neurio==0.3.1 # homeassistant.components.nexia -nexia==1.0.2 +nexia==2.0.1 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 @@ -1120,7 +1126,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.6 +numpy==1.23.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1144,7 +1150,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.0 +onvif-zeep-async==1.2.1 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1153,7 +1159,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.opencv -# opencv-python-headless==4.5.2.54 +# opencv-python-headless==4.6.0.66 # homeassistant.components.openerz openerz-api==0.1.0 @@ -1272,7 +1278,7 @@ proliphix==0.4.1 prometheus_client==0.7.1 # homeassistant.components.proxmoxve -proxmoxer==1.1.1 +proxmoxer==1.3.1 # homeassistant.components.systemmonitor psutil==5.9.0 @@ -1293,7 +1299,7 @@ pushover_complete==1.1.1 pvo==0.2.2 # homeassistant.components.canary -py-canary==0.5.2 +py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 @@ -1330,7 +1336,7 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.29.0 +pyRFXtrx==0.30.0 # homeassistant.components.switchmate # pySwitchmate==0.4.6 @@ -1375,7 +1381,7 @@ pyatmo==6.2.4 pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.10.0 +pyatv==0.10.2 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -1405,10 +1411,10 @@ pycfdns==1.2.2 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==12.1.3 +pychromecast==12.1.4 # homeassistant.components.pocketcasts -pycketcasts==1.0.0 +pycketcasts==1.0.1 # homeassistant.components.climacell pyclimacell==0.18.2 @@ -1438,7 +1444,7 @@ pydaikin==2.7.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==92 +pydeconz==95 # homeassistant.components.delijn pydelijn==1.0.0 @@ -1465,13 +1471,13 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.eight_sleep -pyeight==0.2.0 +pyeight==0.3.0 # homeassistant.components.emby pyemby==1.8 # homeassistant.components.envisalink -pyenvisalink==4.4 +pyenvisalink==4.5 # homeassistant.components.ephember pyephember==0.3.1 @@ -1538,7 +1544,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.5.10 +pyhiveapi==0.5.13 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1549,9 +1555,6 @@ pyhomeworks==0.0.6 # homeassistant.components.ialarm pyialarm==1.9.0 -# homeassistant.components.ialarm_xr -pyialarmxr-homeassistant==1.0.18 - # homeassistant.components.icloud pyicloud==1.0.0 @@ -1559,7 +1562,7 @@ pyicloud==1.0.0 pyinsteon==1.1.1 # homeassistant.components.intesishome -pyintesishome==1.7.6 +pyintesishome==1.8.0 # homeassistant.components.ipma pyipma==2.0.5 @@ -1577,7 +1580,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.6 +pyisy==3.0.7 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -1648,9 +1651,6 @@ pymelcloud==2.5.6 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 -# homeassistant.components.somfy -pymfy==0.11.0 - # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -1673,7 +1673,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.4 +pynetgear==0.10.6 # homeassistant.components.netio pynetio==0.1.9.1 @@ -1723,7 +1723,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.4.0 +pyoverkiz==1.4.2 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1792,7 +1792,7 @@ pysaj==0.0.16 pysdcp==1 # homeassistant.components.sensibo -pysensibo==1.0.14 +pysensibo==1.0.17 # homeassistant.components.serial # homeassistant.components.zha @@ -1838,7 +1838,7 @@ pysmarty==0.8 pysml==0.0.7 # homeassistant.components.snmp -pysnmp==4.4.12 +pysnmplib==5.0.15 # homeassistant.components.soma pysoma==0.0.10 @@ -1993,7 +1993,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.2 +pyunifiprotect==4.0.9 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2014,7 +2014,7 @@ pyvesync==2.0.3 pyvizio==0.1.57 # homeassistant.components.velux -pyvlx==0.2.19 +pyvlx==0.2.20 # homeassistant.components.volumio pyvolumio==0.1.5 @@ -2029,7 +2029,7 @@ pywemo==0.9.1 pywilight==0.0.70 # homeassistant.components.wiz -pywizlight==0.5.13 +pywizlight==0.5.14 # homeassistant.components.ws66i pyws66i==1.1 @@ -2077,7 +2077,7 @@ restrictedpython==5.2 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.62 +rflink==0.0.63 # homeassistant.components.ring ring_doorbell==0.7.2 @@ -2150,7 +2150,7 @@ sendgrid==6.8.2 sense_energy==0.10.4 # homeassistant.components.sentry -sentry-sdk==1.5.12 +sentry-sdk==1.6.0 # homeassistant.components.sharkiq sharkiq==0.0.1 @@ -2168,14 +2168,11 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.06.0 +simplisafe-python==2022.06.1 # homeassistant.components.sisyphus sisyphus-control==3.1.2 -# homeassistant.components.skybell -skybellpy==0.6.3 - # homeassistant.components.slack slackclient==2.5.0 @@ -2192,7 +2189,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.27.1 +soco==0.28.0 # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2219,11 +2216,11 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.19.0 +spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.37 +sqlalchemy==1.4.38 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2271,7 +2268,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridge==2.3.1 +systembridgeconnector==3.1.5 # homeassistant.components.tailscale tailscale==0.2.0 @@ -2292,7 +2289,7 @@ tellcore-py==1.1.2 tellduslive==0.10.11 # homeassistant.components.lg_soundbar -temescal==0.3 +temescal==0.5 # homeassistant.components.temper temperusb==1.5.3 @@ -2301,7 +2298,7 @@ temperusb==1.5.3 # tensorflow==2.5.0 # homeassistant.components.powerwall -tesla-powerwall==0.3.17 +tesla-powerwall==0.3.18 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.1 @@ -2387,10 +2384,10 @@ vallox-websocket-api==2.11.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.5.1 +velbus-aio==2022.6.2 # homeassistant.components.venstar -venstarcolortouch==0.15 +venstarcolortouch==0.17 # homeassistant.components.vilfo vilfo-api-client==0.3.2 @@ -2424,7 +2421,7 @@ wallbox==0.4.9 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.1.8 +watchdog==2.1.9 # homeassistant.components.waterfurnace waterfurnace==1.1.0 @@ -2436,7 +2433,7 @@ webexteamssdk==1.1.1 whirlpool-sixth-sense==0.15.1 # homeassistant.components.whois -whois==0.9.13 +whois==0.9.16 # homeassistant.components.wiffi wiffi==1.1.0 @@ -2460,7 +2457,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.knx -xknx==0.21.3 +xknx==0.21.5 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2468,7 +2465,7 @@ xknx==0.21.3 # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate -xmltodict==0.12.0 +xmltodict==0.13.0 # homeassistant.components.xs1 xs1-api-client==3.0.0 @@ -2498,10 +2495,10 @@ youtube_dl==2021.12.17 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.38.6 +zeroconf==0.38.7 # homeassistant.components.zha -zha-quirks==0.0.75 +zha-quirks==0.0.77 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2510,25 +2507,25 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.16.0 +zigpy-deconz==0.18.0 # homeassistant.components.zha -zigpy-xbee==0.14.0 +zigpy-xbee==0.15.0 # homeassistant.components.zha -zigpy-zigate==0.7.4 +zigpy-zigate==0.9.0 # homeassistant.components.zha -zigpy-znp==0.7.0 +zigpy-znp==0.8.0 # homeassistant.components.zha -zigpy==0.45.1 +zigpy==0.47.2 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.1 +zwave-js-server-python==0.39.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/requirements_test.txt b/requirements_test.txt index 7e4e29de339..6072ce896ee 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,12 +8,12 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt codecov==2.1.12 -coverage==6.4 +coverage==6.4.1 freezegun==1.2.1 mock-open==1.4.0 -mypy==0.960 +mypy==0.961 pre-commit==2.19.0 -pylint==2.13.9 +pylint==2.14.3 pipdeptree==2.2.1 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 @@ -24,10 +24,11 @@ pytest-test-groups==1.0.3 pytest-sugar==0.9.4 pytest-timeout==2.1.0 pytest-xdist==2.5.0 -pytest==7.1.1 +pytest==7.1.2 requests_mock==1.9.2 respx==0.19.0 stdlib-list==0.7.0 +tomli==2.0.1;python_version<"3.11" tqdm==4.49.0 types-atomicwrites==1.4.1 types-croniter==1.0.0 @@ -41,6 +42,6 @@ types-pkg-resources==0.1.3 types-python-slugify==0.1.2 types-pytz==2021.1.2 types-PyYAML==5.4.6 -types-requests==2.25.1 +types-requests==2.28.0 types-toml==0.1.5 types-ujson==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f70484f554..b6a493d37f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,11 +6,14 @@ # homeassistant.components.aemet AEMET-OpenData==0.2.1 +# homeassistant.components.aladdin_connect +AIOAladdinConnect==0.1.21 + # homeassistant.components.adax Adax-local==0.1.4 # homeassistant.components.homekit -HAP-python==4.4.0 +HAP-python==4.5.0 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -30,7 +33,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.13.3 +PySwitchbot==0.14.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -90,11 +93,14 @@ aio_geojson_geonetnz_volcano==0.6 # homeassistant.components.nsw_rural_fire_service_feed aio_geojson_nsw_rfs_incidents==0.4 +# homeassistant.components.usgs_earthquakes_feed +aio_geojson_usgs_earthquakes==0.1 + # homeassistant.components.gdacs aio_georss_gdacs==0.7 # homeassistant.components.airzone -aioairzone==0.4.4 +aioairzone==0.4.5 # homeassistant.components.ambient_station aioambient==2021.11.0 @@ -146,7 +152,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.17 +aiohomekit==0.7.20 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -189,10 +195,10 @@ aiopvapi==1.6.19 aiopvpc==3.0.0 # homeassistant.components.sonarr -aiopyarr==22.2.2 +aiopyarr==22.6.0 # homeassistant.components.qnap_qsw -aioqsw==0.0.8 +aioqsw==0.1.0 # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -209,11 +215,14 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.skybell +aioskybell==22.6.1 + # homeassistant.components.slimproto -aioslimproto==2.0.1 +aioslimproto==2.1.1 # homeassistant.components.steamist -aiosteamist==0.3.1 +aiosteamist==0.3.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -225,7 +234,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==32 +aiounifi==34 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -248,9 +257,6 @@ airthings_cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 -# homeassistant.components.aladdin_connect -aladdin_connect==0.4 - # homeassistant.components.ambee ambee==0.4.0 @@ -264,7 +270,7 @@ ambiclimate==0.2.1 androidtv[async]==0.0.67 # homeassistant.components.apprise -apprise==0.9.8.3 +apprise==0.9.9 # homeassistant.components.aprs aprslib==0.7.0 @@ -278,7 +284,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.1 +async-upnp-client==0.31.2 # homeassistant.components.sleepiq asyncsleepiq==1.2.3 @@ -287,11 +293,7 @@ asyncsleepiq==1.2.3 auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone -aurorapy==0.2.6 - -# homeassistant.components.generic -# homeassistant.components.stream -av==9.2.0 +aurorapy==0.2.7 # homeassistant.components.axis axis==44 @@ -306,13 +308,13 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.30.0 +bellows==0.31.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.4 +bimmer_connected==0.9.6 # homeassistant.components.blebox -blebox_uniapi==1.3.3 +blebox_uniapi==2.0.0 # homeassistant.components.blink blinkpy==0.19.0 @@ -330,7 +332,7 @@ bravia-tv==1.0.11 broadlink==0.18.2 # homeassistant.components.brother -brother==1.1.0 +brother==1.2.3 # homeassistant.components.brunt brunt==1.2.0 @@ -342,7 +344,7 @@ bsblan==0.5.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==0.9.0 +caldav==0.9.1 # homeassistant.components.co2signal co2signal==0.4.2 @@ -456,6 +458,9 @@ feedparser==6.0.2 # homeassistant.components.fibaro fiblary3==0.1.8 +# homeassistant.components.file +file-read-backwards==2.0.0 + # homeassistant.components.fivem fivem-api==0.1.2 @@ -492,14 +497,11 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.9.0 +gcal-sync==0.10.0 # homeassistant.components.geocaching geocachingapi==0.2.1 -# homeassistant.components.usgs_earthquakes_feed -geojson_client==0.6 - # homeassistant.components.aprs geopy==2.1.0 @@ -535,13 +537,13 @@ goodwe==0.2.15 google-cloud-pubsub==2.11.0 # homeassistant.components.nest -google-nest-sdm==1.8.0 +google-nest-sdm==2.0.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 # homeassistant.components.gree -greeclimate==1.1.1 +greeclimate==1.2.0 # homeassistant.components.greeneye_monitor greeneye_monitor==3.0.3 @@ -555,6 +557,10 @@ growattServer==1.2.2 # homeassistant.components.profiler guppy3==3.1.2 +# homeassistant.components.generic +# homeassistant.components.stream +ha-av==10.0.0b4 + # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -586,16 +592,16 @@ hlk-sw16==0.0.9 hole==0.7.0 # homeassistant.components.workday -holidays==0.13 +holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220601.0 +home-assistant-frontend==20220706.0 # homeassistant.components.home_connect -homeconnect==0.7.0 +homeconnect==0.7.1 # homeassistant.components.homematicip_cloud -homematicip==1.0.2 +homematicip==1.0.3 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -631,7 +637,7 @@ influxdb==5.3.1 insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire -intellifire4py==1.0.2 +intellifire4py==2.0.1 # homeassistant.components.iotawatt iotawattpy==0.1.0 @@ -640,7 +646,7 @@ iotawattpy==0.1.0 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.7.2 +jellyfin-apiclient-python==1.8.1 # homeassistant.components.rest jsonpath==0.82 @@ -666,6 +672,9 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 +# homeassistant.components.life360 +life360==4.1.1 + # homeassistant.components.logi_circle logi_circle==0.2.3 @@ -742,10 +751,10 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.2.4 +nettigo-air-monitor==1.3.0 # homeassistant.components.nexia -nexia==1.0.2 +nexia==2.0.1 # homeassistant.components.discord nextcord==2.0.0a8 @@ -770,7 +779,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.6 +numpy==1.23.0 # homeassistant.components.google oauth2client==4.1.3 @@ -785,7 +794,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.0 +onvif-zeep-async==1.2.1 # homeassistant.components.opengarage open-garage==0.2.0 @@ -877,7 +886,7 @@ pushbullet.py==0.11.0 pvo==0.2.2 # homeassistant.components.canary -py-canary==0.5.2 +py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 @@ -905,7 +914,7 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.29.0 +pyRFXtrx==0.30.0 # homeassistant.components.tibber pyTibber==0.22.3 @@ -932,7 +941,7 @@ pyatag==0.3.5.3 pyatmo==6.2.4 # homeassistant.components.apple_tv -pyatv==0.10.0 +pyatv==0.10.2 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -950,7 +959,7 @@ pybotvac==0.0.23 pycfdns==1.2.2 # homeassistant.components.cast -pychromecast==12.1.3 +pychromecast==12.1.4 # homeassistant.components.climacell pyclimacell==0.18.2 @@ -965,7 +974,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.7.0 # homeassistant.components.deconz -pydeconz==92 +pydeconz==95 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -976,6 +985,9 @@ pyeconet==0.1.15 # homeassistant.components.efergy pyefergy==22.1.1 +# homeassistant.components.eight_sleep +pyeight==0.3.0 + # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1029,7 +1041,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.10 +pyhiveapi==0.5.13 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1037,9 +1049,6 @@ pyhomematic==0.1.77 # homeassistant.components.ialarm pyialarm==1.9.0 -# homeassistant.components.ialarm_xr -pyialarmxr-homeassistant==1.0.18 - # homeassistant.components.icloud pyicloud==1.0.0 @@ -1059,7 +1068,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.6 +pyisy==3.0.7 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 @@ -1112,9 +1121,6 @@ pymelcloud==2.5.6 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 -# homeassistant.components.somfy -pymfy==0.11.0 - # homeassistant.components.mochad pymochad==0.2.0 @@ -1131,7 +1137,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.4 +pynetgear==0.10.6 # homeassistant.components.nina pynina==0.1.8 @@ -1169,7 +1175,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.4.0 +pyoverkiz==1.4.2 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1211,7 +1217,7 @@ pyruckus==0.12 pysabnzbd==1.1.1 # homeassistant.components.sensibo -pysensibo==1.0.14 +pysensibo==1.0.17 # homeassistant.components.serial # homeassistant.components.zha @@ -1241,6 +1247,9 @@ pysmartapp==0.3.3 # homeassistant.components.smartthings pysmartthings==0.7.6 +# homeassistant.components.snmp +pysnmplib==5.0.15 + # homeassistant.components.soma pysoma==0.0.10 @@ -1322,7 +1331,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.2 +pyunifiprotect==4.0.9 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1349,7 +1358,7 @@ pywemo==0.9.1 pywilight==0.0.70 # homeassistant.components.wiz -pywizlight==0.5.13 +pywizlight==0.5.14 # homeassistant.components.ws66i pyws66i==1.1 @@ -1363,6 +1372,9 @@ rachiopy==1.0.3 # homeassistant.components.radio_browser radios==0.1.1 +# homeassistant.components.radiotherm +radiotherm==2.1.0 + # homeassistant.components.rainmachine regenmaschine==2022.06.1 @@ -1373,7 +1385,7 @@ renault-api==0.1.11 restrictedpython==5.2 # homeassistant.components.rflink -rflink==0.0.62 +rflink==0.0.63 # homeassistant.components.ring ring_doorbell==0.7.2 @@ -1416,7 +1428,7 @@ securetar==2022.2.0 sense_energy==0.10.4 # homeassistant.components.sentry -sentry-sdk==1.5.12 +sentry-sdk==1.6.0 # homeassistant.components.sharkiq sharkiq==0.0.1 @@ -1424,8 +1436,11 @@ sharkiq==0.0.1 # homeassistant.components.sighthound simplehound==0.3 +# homeassistant.components.simplepush +simplepush==1.1.4 + # homeassistant.components.simplisafe -simplisafe-python==2022.06.0 +simplisafe-python==2022.06.1 # homeassistant.components.slack slackclient==2.5.0 @@ -1437,7 +1452,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.27.1 +soco==0.28.0 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1461,11 +1476,11 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.19.0 +spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.37 +sqlalchemy==1.4.38 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1498,7 +1513,7 @@ sunwatcher==0.2.1 surepy==0.7.2 # homeassistant.components.system_bridge -systembridge==2.3.1 +systembridgeconnector==3.1.5 # homeassistant.components.tailscale tailscale==0.2.0 @@ -1506,8 +1521,11 @@ tailscale==0.2.0 # homeassistant.components.tellduslive tellduslive==0.10.11 +# homeassistant.components.lg_soundbar +temescal==0.5 + # homeassistant.components.powerwall -tesla-powerwall==0.3.17 +tesla-powerwall==0.3.18 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.1 @@ -1569,10 +1587,10 @@ vallox-websocket-api==2.11.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.5.1 +velbus-aio==2022.6.2 # homeassistant.components.venstar -venstarcolortouch==0.15 +venstarcolortouch==0.17 # homeassistant.components.vilfo vilfo-api-client==0.3.2 @@ -1594,13 +1612,13 @@ wakeonlan==2.0.1 wallbox==0.4.9 # homeassistant.components.folder_watcher -watchdog==2.1.8 +watchdog==2.1.9 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.15.1 # homeassistant.components.whois -whois==0.9.13 +whois==0.9.16 # homeassistant.components.wiffi wiffi==1.1.0 @@ -1618,7 +1636,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.knx -xknx==0.21.3 +xknx==0.21.5 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1626,7 +1644,7 @@ xknx==0.21.3 # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate -xmltodict==0.12.0 +xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.8 @@ -1644,28 +1662,28 @@ yolink-api==0.0.8 youless-api==0.16 # homeassistant.components.zeroconf -zeroconf==0.38.6 +zeroconf==0.38.7 # homeassistant.components.zha -zha-quirks==0.0.75 +zha-quirks==0.0.77 # homeassistant.components.zha -zigpy-deconz==0.16.0 +zigpy-deconz==0.18.0 # homeassistant.components.zha -zigpy-xbee==0.14.0 +zigpy-xbee==0.15.0 # homeassistant.components.zha -zigpy-zigate==0.7.4 +zigpy-zigate==0.9.0 # homeassistant.components.zha -zigpy-znp==0.7.0 +zigpy-znp==0.8.0 # homeassistant.components.zha -zigpy==0.45.1 +zigpy==0.47.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.1 +zwave-js-server-python==0.39.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 047dcbf90ad..0d204771e40 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -12,5 +12,5 @@ mccabe==0.6.1 pycodestyle==2.8.0 pydocstyle==6.1.1 pyflakes==2.4.0 -pyupgrade==2.32.1 +pyupgrade==2.34.0 yamllint==1.26.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 0fd31430aa4..11e88976e83 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 """Generate an updated requirements_all.txt.""" -import configparser import difflib import importlib import os @@ -12,6 +11,11 @@ import sys from homeassistant.util.yaml.loader import load_yaml from script.hassfest.model import Integration +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + COMMENT_REQUIREMENTS = ( "Adafruit_BBIO", "avea", # depends on bluepy @@ -26,7 +30,6 @@ COMMENT_REQUIREMENTS = ( "opencv-python-headless", "pybluez", "pycups", - "PySwitchbot", "pySwitchmate", "python-eq3bt", "python-gammu", @@ -102,6 +105,9 @@ httpcore==0.15.0 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 +# Ensure we run compatible with musllinux build env +numpy==1.23.0 + # pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead pytest_asyncio==1000000000.0.0 @@ -170,10 +176,10 @@ def explore_module(package, explore_children): def core_requirements(): - """Gather core requirements out of setup.cfg.""" - parser = configparser.ConfigParser() - parser.read("setup.cfg") - return parser["options"]["install_requires"].strip().split("\n") + """Gather core requirements out of pyproject.toml.""" + with open("pyproject.toml", "rb") as fp: + data = tomllib.load(fp) + return data["project"]["dependencies"] def gather_recursive_requirements(domain, seen=None): diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index d2ee6182f22..5511bc8a518 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -12,6 +12,7 @@ BASE = """ # Home Assistant Core setup.cfg @home-assistant/core +pyproject.toml @home-assistant/core /homeassistant/*.py @home-assistant/core /homeassistant/helpers/ @home-assistant/core /homeassistant/util/ @home-assistant/core diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 7f2e8e0d477..0cd20364533 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -56,6 +56,7 @@ NO_IOT_CLASS = [ "hardware", "history", "homeassistant", + "homeassistant_yellow", "image", "input_boolean", "input_button", diff --git a/script/hassfest/metadata.py b/script/hassfest/metadata.py index ab5ba3f036d..48459eacb72 100644 --- a/script/hassfest/metadata.py +++ b/script/hassfest/metadata.py @@ -1,31 +1,36 @@ """Package metadata validation.""" -import configparser +import sys from homeassistant.const import REQUIRED_PYTHON_VER, __version__ from .model import Config, Integration +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate project metadata keys.""" - metadata_path = config.root / "setup.cfg" - parser = configparser.ConfigParser() - parser.read(metadata_path) + metadata_path = config.root / "pyproject.toml" + with open(metadata_path, "rb") as fp: + data = tomllib.load(fp) try: - if parser["metadata"]["version"] != __version__: + if data["project"]["version"] != __version__: config.add_error( - "metadata", f"'metadata.version' value does not match '{__version__}'" + "metadata", f"'project.version' value does not match '{__version__}'" ) except KeyError: config.add_error("metadata", "No 'metadata.version' key found!") required_py_version = f">={'.'.join(map(str, REQUIRED_PYTHON_VER))}" try: - if parser["options"]["python_requires"] != required_py_version: + if data["project"]["requires-python"] != required_py_version: config.add_error( "metadata", - f"'options.python_requires' value doesn't match '{required_py_version}", + f"'project.requires-python' value doesn't match '{required_py_version}", ) except KeyError: config.add_error("metadata", "No 'options.python_requires' key found!") diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 0b705fab983..b4df9e00495 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -64,9 +64,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.lovelace.dashboard", "homeassistant.components.lovelace.resources", "homeassistant.components.lovelace.websocket", - "homeassistant.components.lutron_caseta", - "homeassistant.components.lutron_caseta.device_trigger", - "homeassistant.components.lutron_caseta.switch", "homeassistant.components.lyric.climate", "homeassistant.components.lyric.config_flow", "homeassistant.components.lyric.sensor", @@ -87,13 +84,8 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.omnilogic.switch", "homeassistant.components.onvif.base", "homeassistant.components.onvif.binary_sensor", - "homeassistant.components.onvif.button", "homeassistant.components.onvif.camera", - "homeassistant.components.onvif.config_flow", "homeassistant.components.onvif.device", - "homeassistant.components.onvif.event", - "homeassistant.components.onvif.models", - "homeassistant.components.onvif.parsers", "homeassistant.components.onvif.sensor", "homeassistant.components.philips_js", "homeassistant.components.philips_js.config_flow", @@ -129,8 +121,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.unifi.device_tracker", "homeassistant.components.unifi.diagnostics", "homeassistant.components.unifi.unifi_entity_base", - "homeassistant.components.vizio.config_flow", - "homeassistant.components.vizio.media_player", "homeassistant.components.withings", "homeassistant.components.withings.binary_sensor", "homeassistant.components.withings.common", @@ -141,10 +131,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xbox.browse_media", "homeassistant.components.xbox.media_source", "homeassistant.components.xbox.sensor", - "homeassistant.components.xiaomi_aqara", - "homeassistant.components.xiaomi_aqara.binary_sensor", - "homeassistant.components.xiaomi_aqara.lock", - "homeassistant.components.xiaomi_aqara.sensor", "homeassistant.components.xiaomi_miio", "homeassistant.components.xiaomi_miio.air_quality", "homeassistant.components.xiaomi_miio.binary_sensor", @@ -155,45 +141,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.light", "homeassistant.components.xiaomi_miio.sensor", "homeassistant.components.xiaomi_miio.switch", - "homeassistant.components.zha.alarm_control_panel", - "homeassistant.components.zha.api", - "homeassistant.components.zha.binary_sensor", - "homeassistant.components.zha.button", - "homeassistant.components.zha.climate", - "homeassistant.components.zha.config_flow", - "homeassistant.components.zha.core.channels", - "homeassistant.components.zha.core.channels.base", - "homeassistant.components.zha.core.channels.closures", - "homeassistant.components.zha.core.channels.general", - "homeassistant.components.zha.core.channels.homeautomation", - "homeassistant.components.zha.core.channels.hvac", - "homeassistant.components.zha.core.channels.lighting", - "homeassistant.components.zha.core.channels.lightlink", - "homeassistant.components.zha.core.channels.manufacturerspecific", - "homeassistant.components.zha.core.channels.measurement", - "homeassistant.components.zha.core.channels.protocol", - "homeassistant.components.zha.core.channels.security", - "homeassistant.components.zha.core.channels.smartenergy", - "homeassistant.components.zha.core.decorators", - "homeassistant.components.zha.core.device", - "homeassistant.components.zha.core.discovery", - "homeassistant.components.zha.core.gateway", - "homeassistant.components.zha.core.group", - "homeassistant.components.zha.core.helpers", - "homeassistant.components.zha.core.registries", - "homeassistant.components.zha.core.store", - "homeassistant.components.zha.core.typing", - "homeassistant.components.zha.cover", - "homeassistant.components.zha.device_action", - "homeassistant.components.zha.device_tracker", - "homeassistant.components.zha.entity", - "homeassistant.components.zha.fan", - "homeassistant.components.zha.light", - "homeassistant.components.zha.lock", - "homeassistant.components.zha.select", - "homeassistant.components.zha.sensor", - "homeassistant.components.zha.siren", - "homeassistant.components.zha.switch", ] # Component modules which should set no_implicit_reexport = true. diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 0dcdbc133a6..a1f520808f6 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -224,6 +224,9 @@ def gen_strings_schema(config: Config, integration: Integration): ), slug_validator=vol.Any("_", cv.slug), ), + vol.Optional("application_credentials"): { + vol.Optional("description"): cv.string_with_no_html, + }, } ) diff --git a/script/version_bump.py b/script/version_bump.py index d714c5183b7..f7dc37b5e22 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -121,13 +121,13 @@ def write_version(version): def write_version_metadata(version: Version) -> None: - """Update setup.cfg file with new version.""" - with open("setup.cfg") as fp: + """Update pyproject.toml file with new version.""" + with open("pyproject.toml", encoding="utf8") as fp: content = fp.read() - content = re.sub(r"(version\W+=\W).+\n", f"\\g<1>{version}\n", content, count=1) + content = re.sub(r"(version\W+=\W).+\n", f'\\g<1>"{version}"\n', content, count=1) - with open("setup.cfg", "w") as fp: + with open("pyproject.toml", "w", encoding="utf8") as fp: fp.write(content) diff --git a/setup.cfg b/setup.cfg index ab5ab807491..b1a2172f8f1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,35 +1,8 @@ -[metadata] -version = 2022.6.7 -url = https://www.home-assistant.io/ +# Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). +# Keep this file until it does! -[options] -python_requires = >=3.9.0 -install_requires = - aiohttp==3.8.1 - astral==2.2 - async_timeout==4.0.2 - attrs==21.2.0 - atomicwrites==1.4.0 - awesomeversion==22.5.2 - bcrypt==3.1.7 - certifi>=2021.5.30 - ciso8601==2.2.0 - # When bumping httpx, please check the version pins of - # httpcore, anyio, and h11 in gen_requirements_all - httpx==0.23.0 - ifaddr==0.1.7 - jinja2==3.1.2 - PyJWT==2.4.0 - # PyJWT has loose dependency. We want the latest one. - cryptography==36.0.2 - pip>=21.0,<22.2 - python-slugify==4.0.1 - pyyaml==6.0 - requests==2.27.1 - typing-extensions>=3.10.0.2,<5.0 - voluptuous==0.13.1 - voluptuous-serialize==2.5.0 - yarl==1.7.2 +[metadata] +url = https://www.home-assistant.io/ [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 53c2a4261ae..22d720da587 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import auth, data_entry_flow from homeassistant.auth import ( + EVENT_USER_UPDATED, InvalidAuthError, auth_store, const as auth_const, @@ -1097,3 +1098,20 @@ async def test_rename_does_not_change_refresh_token(mock_hass): token_after = list(user.refresh_tokens.values())[0] assert token_before == token_after + + +async def test_event_user_updated_fires(hass): + """Test the user updated event fires.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + await manager.async_create_refresh_token(user, CLIENT_ID) + + assert len(list(user.refresh_tokens.values())) == 1 + + events = async_capture_events(hass, EVENT_USER_UPDATED) + + await manager.async_update_user(user, name="new name") + assert user.name == "new name" + + await hass.async_block_till_done() + assert len(events) == 1 diff --git a/tests/common.py b/tests/common.py index bd0b828737b..80f0913cace 100644 --- a/tests/common.py +++ b/tests/common.py @@ -378,7 +378,10 @@ def async_fire_time_changed( ) -> None: """Fire a time changed event.""" if datetime_ is None: - datetime_ = date_util.utcnow() + utc_datetime = date_util.utcnow() + else: + utc_datetime = date_util.as_utc(datetime_) + timestamp = date_util.utc_to_timestamp(utc_datetime) for task in list(hass.loop._scheduled): if not isinstance(task, asyncio.TimerHandle): @@ -386,13 +389,16 @@ def async_fire_time_changed( if task.cancelled(): continue - mock_seconds_into_future = datetime_.timestamp() - time.time() + mock_seconds_into_future = timestamp - time.time() future_seconds = task.when() - hass.loop.time() if fire_all or mock_seconds_into_future >= future_seconds: with patch( "homeassistant.helpers.event.time_tracker_utcnow", - return_value=date_util.as_utc(datetime_), + return_value=utc_datetime, + ), patch( + "homeassistant.helpers.event.time_tracker_timestamp", + return_value=timestamp, ): task._run() task.cancel() @@ -1001,6 +1007,11 @@ class MockEntity(entity.Entity): """Return the entity category.""" return self._handle("entity_category") + @property + def has_entity_name(self): + """Return the has_entity_name name flag.""" + return self._handle("has_entity_name") + @property def entity_registry_enabled_default(self): """Return if the entity should be enabled when first added to the entity registry.""" diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index d27a07227d0..3a1adc069e4 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -4,8 +4,12 @@ from unittest.mock import patch from homeassistant.components.abode import ATTR_DEVICE_ID from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, DOMAIN as LIGHT_DOMAIN, ) from homeassistant.const import ( @@ -41,13 +45,18 @@ async def test_attributes(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert state.attributes.get(ATTR_BRIGHTNESS) == 204 assert state.attributes.get(ATTR_RGB_COLOR) == (0, 63, 255) - assert state.attributes.get(ATTR_COLOR_TEMP) == 280 + assert state.attributes.get(ATTR_COLOR_TEMP) is None assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a" assert not state.attributes.get("battery_low") assert not state.attributes.get("no_response") assert state.attributes.get("device_type") == "RGB Dimmer" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Living Room Lamp" - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 19 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 + assert state.attributes.get(ATTR_COLOR_MODE) == COLOR_MODE_HS + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + ] async def test_switch_off(hass: HomeAssistant) -> None: diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 02ace5d3f1d..97f588cb477 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -46,7 +46,7 @@ async def test_weather_without_forecast(hass): assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6 assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 4.03 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5 # 4.03 m/s -> km/h assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION entry = registry.async_get("weather.home") @@ -68,7 +68,7 @@ async def test_weather_with_forecast(hass): assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6 assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 4.03 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5 # 4.03 m/s -> km/h assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" @@ -78,7 +78,7 @@ async def test_weather_with_forecast(hass): assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 15.4 assert forecast.get(ATTR_FORECAST_TIME) == "2020-07-26T05:00:00+00:00" assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 166 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 3.61 + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 13.0 # 3.61 m/s -> km/h entry = registry.async_get("weather.home") assert entry diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 809b61e0bda..ee021cc7f6d 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -42,10 +42,10 @@ async def test_aemet_weather(hass): assert state.state == ATTR_CONDITION_SNOWY assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 100440.0 + 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_SPEED) == 4.17 + 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 assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None @@ -57,7 +57,7 @@ async def test_aemet_weather(hass): == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 5.56 + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py new file mode 100644 index 00000000000..ee68d207361 --- /dev/null +++ b/tests/components/aladdin_connect/conftest.py @@ -0,0 +1,39 @@ +"""Fixtures for the Aladdin Connect integration tests.""" +from unittest import mock +from unittest.mock import AsyncMock + +import pytest + +DEVICE_CONFIG_OPEN = { + "device_id": 533255, + "door_number": 1, + "name": "home", + "status": "open", + "link_status": "Connected", +} + + +@pytest.fixture(name="mock_aladdinconnect_api") +def fixture_mock_aladdinconnect_api(): + """Set up aladdin connect API fixture.""" + with mock.patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient" + ) as mock_opener: + mock_opener.login = AsyncMock(return_value=True) + mock_opener.close = AsyncMock(return_value=True) + + mock_opener.async_get_door_status = AsyncMock(return_value="open") + mock_opener.get_door_status.return_value = "open" + mock_opener.async_get_door_link_status = AsyncMock(return_value="connected") + mock_opener.get_door_link_status.return_value = "connected" + mock_opener.async_get_battery_status = AsyncMock(return_value="99") + mock_opener.get_battery_status.return_value = "99" + mock_opener.async_get_rssi_status = AsyncMock(return_value="-55") + mock_opener.get_rssi_status.return_value = "-55" + mock_opener.get_doors = AsyncMock(return_value=[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) + + yield mock_opener diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index 899aa0a7e55..33117c64110 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -1,5 +1,7 @@ """Test the Aladdin Connect config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +from aiohttp.client_exceptions import ClientConnectionError from homeassistant import config_entries from homeassistant.components.aladdin_connect.const import DOMAIN @@ -14,8 +16,9 @@ from homeassistant.data_entry_flow import ( from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_aladdinconnect_api: MagicMock) -> None: """Test we get the form.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -23,11 +26,10 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ), patch( - "homeassistant.components.aladdin_connect.async_setup_entry", - return_value=True, + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,33 +46,21 @@ async def test_form(hass: HomeAssistant) -> None: CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", } + assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_failed_auth(hass: HomeAssistant) -> None: +async def test_form_failed_auth( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test we handle failed authentication error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - + mock_aladdinconnect_api.login.return_value = False with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=False, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"] == {"base": "invalid_auth"} - - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=False, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -84,7 +74,33 @@ async def test_form_failed_auth(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_already_configured(hass): +async def test_form_connection_timeout( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test we handle http timeout error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_aladdinconnect_api.login.side_effect = ClientConnectionError + with patch( + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_already_configured( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +): """Test we handle already configured error.""" mock_entry = MockConfigEntry( domain=DOMAIN, @@ -101,8 +117,8 @@ async def test_form_already_configured(hass): assert result["step_id"] == config_entries.SOURCE_USER with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -117,18 +133,15 @@ async def test_form_already_configured(hass): assert result2["reason"] == "already_configured" -async def test_import_flow_success(hass: HomeAssistant) -> None: +async def test_import_flow_success( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test a successful import of yaml.""" - with patch( - "homeassistant.components.aladdin_connect.cover.async_setup_platform", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ), patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.async_setup_entry", - return_value=True, + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_init( DOMAIN, @@ -149,7 +162,9 @@ async def test_import_flow_success(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_reauth_flow(hass: HomeAssistant) -> None: +async def test_reauth_flow( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -174,14 +189,11 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.aladdin_connect.cover.async_setup_platform", + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True, ), patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.async_setup_entry", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -197,7 +209,9 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: } -async def test_reauth_flow_auth_error(hass: HomeAssistant) -> None: +async def test_reauth_flow_auth_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test an authorization error reauth flow.""" mock_entry = MockConfigEntry( @@ -220,13 +234,13 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} - + mock_aladdinconnect_api.login.return_value = False with patch( - "homeassistant.components.aladdin_connect.cover.async_setup_platform", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ), patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=False, + "homeassistant.components.aladdin_connect.cover.async_setup_entry", + return_value=True, ), patch( "homeassistant.components.aladdin_connect.cover.async_setup_entry", return_value=True, @@ -239,3 +253,44 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant) -> None: assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_flow_connnection_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test a connection error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-username", "password": "test-password"}, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data={"username": "test-username", "password": "new-password"}, + ) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + mock_aladdinconnect_api.login.side_effect = ClientConnectionError + + with patch( + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index c1571ed9fa2..54ec4ee5de1 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -1,23 +1,29 @@ """Test the Aladdin Connect Cover.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.aladdin_connect.const import DOMAIN +from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed YAML_CONFIG = {"username": "test-user", "password": "test-password"} @@ -76,63 +82,11 @@ DEVICE_CONFIG_BAD_NO_DOOR = { } -async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: - """Test component setup Get Doors Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=None, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is True - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 - - -async def test_setup_login_error(hass: HomeAssistant) -> None: - """Test component setup Login Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=False, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False - - -async def test_setup_component_noerror(hass: HomeAssistant) -> None: - """Test component setup No Error.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ): - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state == ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -async def test_cover_operation(hass: HomeAssistant) -> None: - """Test component setup open cover, close cover.""" +async def test_cover_operation( + hass: HomeAssistant, + mock_aladdinconnect_api: MagicMock, +) -> None: + """Test Cover Operation states (open,close,opening,closing) cover.""" config_entry = MockConfigEntry( domain=DOMAIN, data=YAML_CONFIG, @@ -142,92 +96,116 @@ async def test_cover_operation(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_OPEN) + mock_aladdinconnect_api.get_door_status.return_value = STATE_OPEN with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_OPEN], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert COVER_DOMAIN in hass.config.components - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.open_door", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_OPEN], - ): - await hass.services.async_call( - "cover", "open_cover", {"entity_id": "cover.home"}, blocking=True - ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPEN + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) + mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.close_door", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_CLOSED], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): + await hass.services.async_call( - "cover", "close_cover", {"entity_id": "cover.home"}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, ) + await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + assert hass.states.get("cover.home").state == STATE_CLOSED - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_OPENING], - ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True - ) - assert hass.states.get("cover.home").state == STATE_OPENING + mock_aladdinconnect_api.async_get_door_status = AsyncMock( + return_value=STATE_CLOSING + ) + mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_CLOSING], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSING - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_BAD], - ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True - ) - assert hass.states.get("cover.home").state + mock_aladdinconnect_api.async_get_door_status = AsyncMock( + return_value=STATE_OPENING + ) + mock_aladdinconnect_api.get_door_status.return_value = STATE_OPENING with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_BAD_NO_DOOR], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, ) - assert hass.states.get("cover.home").state + await hass.async_block_till_done() + assert hass.states.get("cover.home").state == STATE_OPENING + + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=None) + mock_aladdinconnect_api.get_door_status.return_value = None + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + assert hass.states.get("cover.home").state == STATE_UNKNOWN -async def test_yaml_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture): +async def test_yaml_import( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_aladdinconnect_api: MagicMock, +): """Test setup YAML import.""" assert COVER_DOMAIN not in hass.config.components with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_CLOSED], + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): await async_setup_component( hass, @@ -248,3 +226,37 @@ async def test_yaml_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture config_data = hass.config_entries.async_entries(DOMAIN)[0].data assert config_data[CONF_USERNAME] == "test-user" assert config_data[CONF_PASSWORD] == "test-password" + + +async def test_callback( + hass: HomeAssistant, + mock_aladdinconnect_api: MagicMock, +): + """Test callback from Aladdin Connect API.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + 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() + + mock_aladdinconnect_api.async_get_door_status.return_value = STATE_CLOSING + mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ), patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient._call_back", + AsyncMock(), + ): + callback = mock_aladdinconnect_api.register_callback.call_args[0][0] + await callback() + assert hass.states.get("cover.home").state == STATE_CLOSING diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 0ba9b317dfb..4c422ae29ba 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -1,35 +1,77 @@ """Test for Aladdin Connect init logic.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +from aiohttp import ClientConnectionError from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import AsyncMock, MockConfigEntry YAML_CONFIG = {"username": "test-user", "password": "test-password"} -async def test_entry_password_fail(hass: HomeAssistant): - """Test password fail during entry.""" - entry = MockConfigEntry( +async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: + """Test component setup Get Doors Errors.""" + config_entry = MockConfigEntry( domain=DOMAIN, - data={"username": "test-user", "password": "test-password"}, + data=YAML_CONFIG, + unique_id="test-id", ) - entry.add_to_hass(hass) - + config_entry.add_to_hass(hass) with patch( "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=False, + return_value=True, + ), patch( + "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", + return_value=None, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_setup_login_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test component setup Login Errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + mock_aladdinconnect_api.login.return_value = False + with patch( + "homeassistant.components.aladdin_connect.cover.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_ERROR + assert await hass.config_entries.async_setup(config_entry.entry_id) is False -async def test_load_and_unload(hass: HomeAssistant) -> None: - """Test loading and unloading Aladdin Connect entry.""" +async def test_setup_connection_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test component setup Login Errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + mock_aladdinconnect_api.login.side_effect = ClientConnectionError + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + + assert await hass.config_entries.async_setup(config_entry.entry_id) is False + + +async def test_setup_component_no_error(hass: HomeAssistant) -> None: + """Test component setup No Error.""" config_entry = MockConfigEntry( domain=DOMAIN, data=YAML_CONFIG, @@ -41,6 +83,49 @@ async def test_load_and_unload(hass: HomeAssistant) -> None: return_value=True, ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_entry_password_fail( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +): + """Test password fail during entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-user", "password": "test-password"}, + ) + entry.add_to_hass(hass) + mock_aladdinconnect_api.login = AsyncMock(return_value=False) + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_load_and_unload( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test loading and unloading Aladdin Connect entry.""" + 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() diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 4cccae1f083..ea6c96bbaef 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -658,13 +658,16 @@ async def test_report_climate_state(hass): "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} ) - hass.states.async_set( - "climate.unavailable", - "unavailable", - {"friendly_name": "Climate Unavailable", "supported_features": 91}, - ) - properties = await reported_properties(hass, "climate.unavailable") - properties.assert_not_has_property("Alexa.ThermostatController", "thermostatMode") + for state in "unavailable", "unknown": + hass.states.async_set( + f"climate.{state}", + state, + {"friendly_name": f"Climate {state}", "supported_features": 91}, + ) + properties = await reported_properties(hass, f"climate.{state}") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) hass.states.async_set( "climate.unsupported", @@ -846,6 +849,57 @@ async def test_report_image_processing(hass): ) +@pytest.mark.parametrize("domain", ["button", "input_button"]) +async def test_report_button_pressed(hass, domain): + """Test button presses report human presence detection events to trigger routines.""" + hass.states.async_set( + f"{domain}.test_button", "now", {"friendly_name": "Test button"} + ) + + properties = await reported_properties(hass, f"{domain}#test_button") + properties.assert_equal( + "Alexa.EventDetectionSensor", + "humanPresenceDetectionState", + {"value": "DETECTED"}, + ) + + +@pytest.mark.parametrize("domain", ["switch", "input_boolean"]) +async def test_toggle_entities_report_contact_events(hass, domain): + """Test toggles and switches report contact sensor events to trigger routines.""" + hass.states.async_set( + f"{domain}.test_toggle", "on", {"friendly_name": "Test toggle"} + ) + + properties = await reported_properties(hass, f"{domain}#test_toggle") + properties.assert_equal( + "Alexa.PowerController", + "powerState", + "ON", + ) + properties.assert_equal( + "Alexa.ContactSensor", + "detectionState", + "DETECTED", + ) + + hass.states.async_set( + f"{domain}.test_toggle", "off", {"friendly_name": "Test toggle"} + ) + + properties = await reported_properties(hass, f"{domain}#test_toggle") + properties.assert_equal( + "Alexa.PowerController", + "powerState", + "OFF", + ) + properties.assert_equal( + "Alexa.ContactSensor", + "detectionState", + "NOT_DETECTED", + ) + + async def test_get_property_blowup(hass, caplog): """Test we handle a property blowing up.""" hass.states.async_set( diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index f15fa860c7b..9c71bc32e4d 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -490,8 +490,11 @@ async def test_intent_session_ended_request(alexa_client): req = await _intent_req(alexa_client, data) assert req.status == HTTPStatus.OK - text = await req.text() - assert text == "" + data = await req.json() + assert ( + data["response"]["outputSpeech"]["text"] + == "This intent is not yet configured within Home Assistant." + ) async def test_intent_from_built_in_intent_library(alexa_client): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 37888a2c415..0169eeff9d5 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -182,8 +182,12 @@ async def test_switch(hass, events): assert appliance["endpointId"] == "switch#test" assert appliance["displayCategories"][0] == "SWITCH" assert appliance["friendlyName"] == "Test switch" - assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ContactSensor", + "Alexa.EndpointHealth", + "Alexa", ) await assert_power_controller_works( @@ -192,6 +196,14 @@ async def test_switch(hass, events): properties = await reported_properties(hass, "switch#test") properties.assert_equal("Alexa.PowerController", "powerState", "ON") + properties.assert_equal("Alexa.ContactSensor", "detectionState", "DETECTED") + properties.assert_equal("Alexa.EndpointHealth", "connectivity", {"value": "OK"}) + + contact_sensor_capability = get_capability(capabilities, "Alexa.ContactSensor") + assert contact_sensor_capability is not None + properties = contact_sensor_capability["properties"] + assert properties["retrievable"] is True + assert {"name": "detectionState"} in properties["supported"] async def test_outlet(hass, events): @@ -207,7 +219,11 @@ async def test_outlet(hass, events): assert appliance["displayCategories"][0] == "SMARTPLUG" assert appliance["friendlyName"] == "Test switch" assert_endpoint_capabilities( - appliance, "Alexa", "Alexa.PowerController", "Alexa.EndpointHealth" + appliance, + "Alexa", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa.ContactSensor", ) @@ -335,8 +351,12 @@ async def test_input_boolean(hass): assert appliance["endpointId"] == "input_boolean#test" assert appliance["displayCategories"][0] == "OTHER" assert appliance["friendlyName"] == "Test input boolean" - assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ContactSensor", + "Alexa.EndpointHealth", + "Alexa", ) await assert_power_controller_works( @@ -347,6 +367,17 @@ async def test_input_boolean(hass): "2022-04-19T07:53:05Z", ) + properties = await reported_properties(hass, "input_boolean#test") + properties.assert_equal("Alexa.PowerController", "powerState", "OFF") + properties.assert_equal("Alexa.ContactSensor", "detectionState", "NOT_DETECTED") + properties.assert_equal("Alexa.EndpointHealth", "connectivity", {"value": "OK"}) + + contact_sensor_capability = get_capability(capabilities, "Alexa.ContactSensor") + assert contact_sensor_capability is not None + properties = contact_sensor_capability["properties"] + assert properties["retrievable"] is True + assert {"name": "detectionState"} in properties["supported"] + @freeze_time("2022-04-19 07:53:05") async def test_scene(hass): @@ -4003,7 +4034,11 @@ async def test_button(hass, domain): assert appliance["friendlyName"] == "Ring Doorbell" capabilities = assert_endpoint_capabilities( - appliance, "Alexa.SceneController", "Alexa" + appliance, + "Alexa.SceneController", + "Alexa.EventDetectionSensor", + "Alexa.EndpointHealth", + "Alexa", ) scene_capability = get_capability(capabilities, "Alexa.SceneController") assert scene_capability["supportsDeactivation"] is False @@ -4016,6 +4051,21 @@ async def test_button(hass, domain): "2022-04-19T07:53:05Z", ) + event_detection_capability = get_capability( + capabilities, "Alexa.EventDetectionSensor" + ) + assert event_detection_capability is not None + properties = event_detection_capability["properties"] + assert properties["proactivelyReported"] is True + assert not properties["retrievable"] + assert {"name": "humanPresenceDetectionState"} in properties["supported"] + assert ( + event_detection_capability["configuration"]["detectionModes"]["humanPresence"][ + "supportsNotDetected" + ] + is False + ) + async def test_api_message_sets_authorized(hass): """Test an incoming API messages sets the authorized flag.""" diff --git a/tests/components/apache_kafka/test_init.py b/tests/components/apache_kafka/test_init.py index 3f594b3fce3..9f5fa2800bc 100644 --- a/tests/components/apache_kafka/test_init.py +++ b/tests/components/apache_kafka/test_init.py @@ -169,7 +169,7 @@ async def test_filtered_allowlist(hass, mock_client): FilterTest("light.excluded_test", False), FilterTest("light.excluded", False), FilterTest("sensor.included_test", True), - FilterTest("climate.included_test", False), + FilterTest("climate.included_test", True), ] await _run_filter_tests(hass, tests, mock_client) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index b89a60f42e4..dd5995a8f4f 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -701,7 +701,11 @@ async def test_websocket_integration_list(ws_client: ClientFixture): "homeassistant.loader.APPLICATION_CREDENTIALS", ["example1", "example2"] ): assert await client.cmd_result("config") == { - "domains": ["example1", "example2"] + "domains": ["example1", "example2"], + "integrations": { + "example1": {}, + "example2": {}, + }, } diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index e419488becc..c93e6429f1c 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -1,7 +1,10 @@ """Mocks for the august component.""" +from __future__ import annotations + import json import os import time +from typing import Any, Iterable from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from yalexs.activity import ( @@ -26,7 +29,9 @@ from yalexs.lock import Lock, LockDetail from yalexs.pubnub_async import AugustPubNub from homeassistant.components.august.const import CONF_LOGIN_METHOD, DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -76,9 +81,13 @@ async def _mock_setup_august( async def _create_august_with_devices( - hass, devices, api_call_side_effects=None, activities=None, pubnub=None -): - entry, api_instance = await _create_august_api_with_devices( + hass: HomeAssistant, + devices: Iterable[LockDetail | DoorbellDetail], + api_call_side_effects: dict[str, Any] | None = None, + activities: list[Any] | None = None, + pubnub: AugustPubNub | None = None, +) -> ConfigEntry: + entry, _ = await _create_august_api_with_devices( hass, devices, api_call_side_effects, activities, pubnub ) return entry diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 320461ca6e9..56113832d23 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -17,6 +17,9 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.august.mocks import ( @@ -318,3 +321,46 @@ async def test_load_unload(hass): await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + august_operative_lock = await _mock_operative_august_lock_detail(hass) + config_entry = await _create_august_with_devices(hass, [august_operative_lock]) + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, config_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index e53dcf5ab06..b30d6dc5eeb 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -2,7 +2,7 @@ from logging import INFO from unittest.mock import patch -from aurorapy.client import AuroraError +from aurorapy.client import AuroraError, AuroraTimeoutError from serial.tools import list_ports_common from homeassistant import config_entries, data_entry_flow, setup @@ -127,7 +127,7 @@ async def test_form_invalid_com_ports(hass): with patch( "aurorapy.client.AuroraSerialClient.connect", - side_effect=AuroraError("...No response after..."), + side_effect=AuroraTimeoutError("...No response after..."), return_value=None, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 0a7b7e33302..f41750ba017 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from unittest.mock import patch -from aurorapy.client import AuroraError +from aurorapy.client import AuroraError, AuroraTimeoutError from homeassistant.components.aurora_abb_powerone.const import ( ATTR_DEVICE_NAME, @@ -126,7 +126,7 @@ async def test_sensor_dark(hass): # sunset with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", - side_effect=AuroraError("No response after 10 seconds"), + side_effect=AuroraTimeoutError("No response after 10 seconds"), ): async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() @@ -144,7 +144,7 @@ async def test_sensor_dark(hass): # sunset with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", - side_effect=AuroraError("No response after 10 seconds"), + side_effect=AuroraTimeoutError("No response after 10 seconds"), ): async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 3c90d915966..706221d9371 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -194,7 +194,7 @@ async def test_ws_current_user(hass, hass_ws_client, hass_access_token): user = refresh_token.user client = await hass_ws_client(hass, hass_access_token) - await client.send_json({"id": 5, "type": auth.WS_TYPE_CURRENT_USER}) + await client.send_json({"id": 5, "type": "auth/current_user"}) result = await client.receive_json() assert result["success"], result @@ -411,7 +411,7 @@ async def test_ws_long_lived_access_token(hass, hass_ws_client, hass_access_toke await ws_client.send_json( { "id": 5, - "type": auth.WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + "type": "auth/long_lived_access_token", "client_name": "GPS Logger", "lifespan": 365, } @@ -435,7 +435,7 @@ async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token): ws_client = await hass_ws_client(hass, hass_access_token) - await ws_client.send_json({"id": 5, "type": auth.WS_TYPE_REFRESH_TOKENS}) + await ws_client.send_json({"id": 5, "type": "auth/refresh_tokens"}) result = await ws_client.receive_json() assert result["success"], result @@ -451,6 +451,7 @@ async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token): assert token["is_current"] is True assert token["last_used_at"] == refresh_token.last_used_at.isoformat() assert token["last_used_ip"] == refresh_token.last_used_ip + assert token["auth_provider_type"] == "homeassistant" async def test_ws_delete_refresh_token( @@ -469,7 +470,7 @@ async def test_ws_delete_refresh_token( await ws_client.send_json( { "id": 5, - "type": auth.WS_TYPE_DELETE_REFRESH_TOKEN, + "type": "auth/delete_refresh_token", "refresh_token_id": refresh_token.id, } ) @@ -491,7 +492,7 @@ async def test_ws_sign_path(hass, hass_ws_client, hass_access_token): await ws_client.send_json( { "id": 5, - "type": auth.WS_TYPE_SIGN_PATH, + "type": "auth/sign_path", "path": "/api/hello", "expires": 20, } diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index bfb02c0daba..4067393b76c 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -11,7 +11,7 @@ from homeassistant.components.automation import ( ATTR_MODE, CONF_ID, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/awair/test_config_flow.py b/tests/components/awair/test_config_flow.py index 8afe9a1c701..47e58fea421 100644 --- a/tests/components/awair/test_config_flow.py +++ b/tests/components/awair/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant import data_entry_flow from homeassistant.components.awair.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from .const import CONFIG, DEVICES_FIXTURE, NO_DEVICES_FIXTURE, UNIQUE_ID, USER_FIXTURE @@ -82,46 +83,67 @@ async def test_no_devices_error(hass): assert result["reason"] == "no_devices_found" -async def test_reauth(hass): +async def test_reauth(hass: HomeAssistant) -> None: """Test reauth flow.""" - with patch( - "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] - ), patch( - "homeassistant.components.awair.sensor.async_setup_entry", - return_value=True, - ): - mock_config = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG) - mock_config.add_to_hass(hass) - hass.config_entries.async_update_entry( - mock_config, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"} - ) + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=UNIQUE_ID, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"} + ) + mock_config.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, - data=CONFIG, - ) - - assert result["type"] == "abort" - assert result["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, + data={**CONFIG, CONF_ACCESS_TOKEN: "blah"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} with patch("python_awair.AwairClient.query", side_effect=AuthError()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, - data=CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"} - with patch("python_awair.AwairClient.query", side_effect=AwairError()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, - data=CONFIG, + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] + ), patch("homeassistant.components.awair.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_error(hass: HomeAssistant) -> None: + """Test reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=UNIQUE_ID, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"} + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, + data={**CONFIG, CONF_ACCESS_TOKEN: "blah"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + with patch("python_awair.AwairClient.query", side_effect=AwairError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "unknown" diff --git a/tests/components/azure_event_hub/test_init.py b/tests/components/azure_event_hub/test_init.py index cf7226e20b0..c1393483c8c 100644 --- a/tests/components/azure_event_hub/test_init.py +++ b/tests/components/azure_event_hub/test_init.py @@ -176,7 +176,7 @@ async def test_full_batch(hass, entry_with_one_event, mock_create_batch): FilterTest("light.excluded_test", 0), FilterTest("light.excluded", 0), FilterTest("sensor.included_test", 1), - FilterTest("climate.included_test", 0), + FilterTest("climate.included_test", 1), ], ), ( diff --git a/tests/components/blebox/conftest.py b/tests/components/blebox/conftest.py index a63a0090c3a..548c7a5dc38 100644 --- a/tests/components/blebox/conftest.py +++ b/tests/components/blebox/conftest.py @@ -16,12 +16,11 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 def patch_product_identify(path=None, **kwargs): """Patch the blebox_uniapi Products class.""" - if path is None: - path = "homeassistant.components.blebox.Products" - patcher = patch(path, mock.DEFAULT, blebox_uniapi.products.Products, True, True) - products_class = patcher.start() - products_class.async_from_host = AsyncMock(**kwargs) - return products_class + patcher = patch.object( + blebox_uniapi.box.Box, "async_from_host", AsyncMock(**kwargs) + ) + patcher.start() + return blebox_uniapi.box.Box def setup_product_mock(category, feature_mocks, path=None): @@ -84,7 +83,6 @@ async def async_setup_entities(hass, config, entity_ids): config_entry.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) return [entity_registry.async_get(entity_id) for entity_id in entity_ids] diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 03f5d0b4f2a..3f40880abf7 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -77,8 +77,8 @@ async def test_flow_works(hass, valid_feature_mock, flow_feature_mock): @pytest.fixture(name="product_class_mock") def product_class_mock_fixture(): """Return a mocked feature.""" - path = "homeassistant.components.blebox.config_flow.Products" - patcher = patch(path, DEFAULT, blebox_uniapi.products.Products, True, True) + path = "homeassistant.components.blebox.config_flow.Box" + patcher = patch(path, DEFAULT, blebox_uniapi.box.Box, True, True) yield patcher diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index a546424e14b..4663c216136 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -39,6 +39,8 @@ def dimmer_fixture(): is_on=True, supports_color=False, supports_white=False, + color_mode=blebox_uniapi.light.BleboxColorMode.MONO, + effect_list=None, ) product = feature.product type(product).name = PropertyMock(return_value="My dimmer") @@ -210,6 +212,8 @@ def wlightboxs_fixture(): is_on=None, supports_color=False, supports_white=False, + color_mode=blebox_uniapi.light.BleboxColorMode.MONO, + effect_list=["NONE", "PL", "RELAX"], ) product = feature.product type(product).name = PropertyMock(return_value="My wLightBoxS") @@ -310,6 +314,9 @@ def wlightbox_fixture(): supports_white=True, white_value=None, rgbw_hex=None, + color_mode=blebox_uniapi.light.BleboxColorMode.RGBW, + effect="NONE", + effect_list=["NONE", "PL", "POLICE"], ) product = feature.product type(product).name = PropertyMock(return_value="My wLightBox") @@ -379,7 +386,7 @@ async def test_wlightbox_on_rgbw(wlightbox, hass, config): def turn_on(value): feature_mock.is_on = True - assert value == "c1d2f3c7" + assert value == [193, 210, 243, 199] feature_mock.white_value = 0xC7 # on feature_mock.rgbw_hex = "c1d2f3c7" diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 5e3b89002bf..5ea03eb2b62 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -246,7 +246,9 @@ async def test_form_unknown_error(hass): async def test_reauth_shows_user_step(hass): """Test reauth shows the user form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data={"username": "blink@example.com", "password": "invalid_password"}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 9376710abee..eb2d12f5081 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import parse_yaml @pytest.fixture(autouse=True) @@ -130,9 +131,18 @@ async def test_save_blueprint(hass, aioclient_mock, hass_ws_client): assert msg["id"] == 6 assert msg["success"] assert write_mock.mock_calls - assert write_mock.call_args[0] == ( - "blueprint:\n name: Call service based on event\n domain: automation\n input:\n trigger_event:\n selector:\n text: {}\n service_to_call:\n a_number:\n selector:\n number:\n mode: box\n step: 1.0\n source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n platform: event\n event_type: !input 'trigger_event'\naction:\n service: !input 'service_to_call'\n entity_id: light.kitchen\n", + # There are subtle differences in the dumper quoting + # behavior when quoting is not required as both produce + # valid yaml + output_yaml = write_mock.call_args[0][0] + assert output_yaml in ( + # pure python dumper will quote the value after !input + "blueprint:\n name: Call service based on event\n domain: automation\n input:\n trigger_event:\n selector:\n text: {}\n service_to_call:\n a_number:\n selector:\n number:\n mode: box\n step: 1.0\n source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n platform: event\n event_type: !input 'trigger_event'\naction:\n service: !input 'service_to_call'\n entity_id: light.kitchen\n" + # c dumper will not quote the value after !input + "blueprint:\n name: Call service based on event\n domain: automation\n input:\n trigger_event:\n selector:\n text: {}\n service_to_call:\n a_number:\n selector:\n number:\n mode: box\n step: 1.0\n source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n platform: event\n event_type: !input trigger_event\naction:\n service: !input service_to_call\n entity_id: light.kitchen\n" ) + # Make sure ita parsable and does not raise + assert len(parse_yaml(output_yaml)) > 1 async def test_save_existing_file(hass, aioclient_mock, hass_ws_client): diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index d3c7c64dc99..10178c22de8 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -1,19 +1,32 @@ """Test the for the BMW Connected Drive config flow.""" from unittest.mock import patch +from bimmer_connected.api.authentication import MyBMWAuthentication from httpx import HTTPError from homeassistant import config_entries, data_entry_flow from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN -from homeassistant.components.bmw_connected_drive.const import CONF_READ_ONLY +from homeassistant.components.bmw_connected_drive.const import ( + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, +) from homeassistant.const import CONF_USERNAME from . import FIXTURE_CONFIG_ENTRY, FIXTURE_USER_INPUT from tests.common import MockConfigEntry -FIXTURE_COMPLETE_ENTRY = FIXTURE_USER_INPUT.copy() -FIXTURE_IMPORT_ENTRY = FIXTURE_USER_INPUT.copy() +FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" +FIXTURE_COMPLETE_ENTRY = { + **FIXTURE_USER_INPUT, + CONF_REFRESH_TOKEN: FIXTURE_REFRESH_TOKEN, +} +FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None} + + +def login_sideeffect(self: MyBMWAuthentication): + """Mock logging in and setting a refresh token.""" + self.refresh_token = FIXTURE_REFRESH_TOKEN async def test_show_form(hass): @@ -50,8 +63,9 @@ async def test_connection_error(hass): async def test_full_user_flow_implementation(hass): """Test registering an integration and finishing flow works.""" with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - return_value=[], + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=login_sideeffect, + autospec=True, ), patch( "homeassistant.components.bmw_connected_drive.async_setup_entry", return_value=True, diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 909fb35a1e2..f14efcdf172 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -19,6 +19,20 @@ from homeassistant.util import utcnow from tests.common import MockConfigEntry, async_fire_time_changed +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + def ceiling_fan_with_breeze(name: str): """Create a ceiling fan with given name with breeze support.""" return { diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 519fa9dec9d..15aa643abaf 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -18,6 +18,7 @@ from .common import ( patch_bond_device, patch_bond_device_ids, patch_bond_device_properties, + patch_bond_device_state, patch_bond_token, patch_bond_version, ) @@ -38,7 +39,7 @@ async def test_user_form(hass: core.HomeAssistant): return_value={"bondid": "ZXXX12345"} ), patch_bond_device_ids( return_value=["f6776c11", "f6776c12"] - ), patch_bond_bridge(), patch_bond_device_properties(), patch_bond_device(), _patch_async_setup_entry() as mock_setup_entry: + ), patch_bond_bridge(), patch_bond_device_properties(), patch_bond_device(), patch_bond_device_state(), _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -73,7 +74,7 @@ async def test_user_form_with_non_bridge(hass: core.HomeAssistant): } ), patch_bond_bridge( return_value={} - ), _patch_async_setup_entry() as mock_setup_entry: + ), patch_bond_device_state(), _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -381,6 +382,45 @@ async def test_zeroconf_already_configured(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 +async def test_zeroconf_in_setup_retry_state(hass: core.HomeAssistant): + """Test we retry right away on zeroconf discovery.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "test-token"}, + ) + entry.add_to_hass(hass) + + with patch_bond_version(side_effect=OSError): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="updated-host", + addresses=["updated-host"], + hostname="mock_hostname", + name="already-registered-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "updated-host" + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.state is ConfigEntryState.LOADED + + async def test_zeroconf_already_configured_refresh_token(hass: core.HomeAssistant): """Test starting a flow from zeroconf when already configured and the token is out of date.""" entry2 = MockConfigEntry( diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 7c860e68efc..305c131125f 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -33,6 +33,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( + ceiling_fan, help_test_entity_available, patch_bond_action, patch_bond_action_returns_clientresponseerror, @@ -43,15 +44,6 @@ from .common import ( from tests.common import async_fire_time_changed -def ceiling_fan(name: str): - """Create a ceiling fan with given name.""" - return { - "name": name, - "type": DeviceType.CEILING_FAN, - "actions": ["SetSpeed", "SetDirection"], - } - - def ceiling_fan_with_breeze(name: str): """Create a ceiling fan with given name with breeze support.""" return { diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 56087d4bf11..d02e2bed4ec 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -11,7 +11,8 @@ from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .common import ( @@ -24,6 +25,7 @@ from .common import ( patch_bond_version, patch_setup_entry, patch_start_bpup, + remove_device, setup_bond_entity, setup_platform, ) @@ -284,6 +286,65 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant): assert device.suggested_area == "Office" +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + config_entry = await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["fan.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, config_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test-hub-id", "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "wrong-hub-id", "test-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) + + hub_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test-hub-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), hub_device_entry.id, config_entry.entry_id + ) + is False + ) + + async def test_smart_by_bond_v3_firmware(hass: HomeAssistant) -> None: """Test we can detect smart by bond with the v3 firmware.""" await setup_platform( diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 1b30facf1de..ba13bbd6c52 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -3,7 +3,7 @@ import asyncio import base64 from http import HTTPStatus import io -from unittest.mock import Mock, PropertyMock, mock_open, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, mock_open, patch import pytest @@ -410,6 +410,7 @@ async def test_preload_stream(hass, mock_stream): "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="http://example.com", ): + mock_create_stream.return_value.start = AsyncMock() assert await async_setup_component( hass, "camera", {DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index 0dc161fb0c0..1217997a996 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import camera -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ( ATTR_ATTRIBUTION, diff --git a/tests/components/canary/__init__.py b/tests/components/canary/__init__.py index b327fb0ebcb..46737929dc5 100644 --- a/tests/components/canary/__init__.py +++ b/tests/components/canary/__init__.py @@ -1,7 +1,7 @@ """Tests for the Canary integration.""" from unittest.mock import MagicMock, PropertyMock, patch -from canary.api import SensorType +from canary.model import SensorType from homeassistant.components.canary.const import ( CONF_FFMPEG_ARGUMENTS, diff --git a/tests/components/canary/test_alarm_control_panel.py b/tests/components/canary/test_alarm_control_panel.py index 5034792d389..f6eed94e267 100644 --- a/tests/components/canary/test_alarm_control_panel.py +++ b/tests/components/canary/test_alarm_control_panel.py @@ -1,7 +1,7 @@ """The tests for the Canary alarm_control_panel platform.""" from unittest.mock import PropertyMock, patch -from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT +from canary.const import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.components.canary import DOMAIN diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 1ad89c7a8e5..d7aa0fdeda9 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -116,6 +116,26 @@ async def test_zeroconf_setup(hass): } +async def test_zeroconf_setup_onboarding(hass): + """Test we automatically finish a config flow through zeroconf during onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + "cast", context={"source": config_entries.SOURCE_ZEROCONF} + ) + + users = await hass.auth.async_get_users() + assert len(users) == 1 + assert result["type"] == "create_entry" + assert result["result"].data == { + "ignore_cec": [], + "known_hosts": [], + "uuid": [], + "user_id": users[0].id, # Home Assistant cast user + } + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema.keys(): diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index e4df84f6443..00626cc8c16 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -127,7 +127,7 @@ async def async_setup_cast(hass, config=None): config = {} data = {**{"ignore_cec": [], "known_hosts": [], "uuid": []}, **config} with patch( - "homeassistant.helpers.entity_platform.EntityPlatform._async_schedule_add_entities" + "homeassistant.helpers.entity_platform.EntityPlatform._async_schedule_add_entities_for_entry" ) as add_entities: entry = MockConfigEntry(data=data, domain="cast") entry.add_to_hass(hass) diff --git a/tests/components/climacell/test_weather.py b/tests/components/climacell/test_weather.py index 3c02f6b9b1f..e3326f267d4 100644 --- a/tests/components/climacell/test_weather.py +++ b/tests/components/climacell/test_weather.py @@ -132,7 +132,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-12T00:00:00-08:00", - ATTR_FORECAST_PRECIPITATION: 0.0457, + ATTR_FORECAST_PRECIPITATION: 0.05, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, ATTR_FORECAST_TEMP: 19.9, ATTR_FORECAST_TEMP_LOW: 12.1, @@ -148,7 +148,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, ATTR_FORECAST_TIME: "2021-03-14T00:00:00-08:00", - ATTR_FORECAST_PRECIPITATION: 1.0744, + ATTR_FORECAST_PRECIPITATION: 1.07, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 75, ATTR_FORECAST_TEMP: 6.4, ATTR_FORECAST_TEMP_LOW: 3.2, @@ -156,7 +156,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, ATTR_FORECAST_TIME: "2021-03-15T00:00:00-07:00", # DST starts - ATTR_FORECAST_PRECIPITATION: 7.3050, + ATTR_FORECAST_PRECIPITATION: 7.31, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, ATTR_FORECAST_TEMP: 1.2, ATTR_FORECAST_TEMP_LOW: 0.2, @@ -164,7 +164,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-16T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 0.0051, + ATTR_FORECAST_PRECIPITATION: 0.01, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 5, ATTR_FORECAST_TEMP: 6.1, ATTR_FORECAST_TEMP_LOW: -1.6, @@ -188,7 +188,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-19T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 0.1778, + ATTR_FORECAST_PRECIPITATION: 0.18, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 45, ATTR_FORECAST_TEMP: 9.4, ATTR_FORECAST_TEMP_LOW: 4.7, @@ -196,7 +196,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, ATTR_FORECAST_TIME: "2021-03-20T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 1.2319, + ATTR_FORECAST_PRECIPITATION: 1.23, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, ATTR_FORECAST_TEMP: 5.0, ATTR_FORECAST_TEMP_LOW: 3.1, @@ -204,7 +204,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-21T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 0.0432, + ATTR_FORECAST_PRECIPITATION: 0.04, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 20, ATTR_FORECAST_TEMP: 6.8, ATTR_FORECAST_TEMP_LOW: 0.9, @@ -213,11 +213,11 @@ async def test_v3_weather( assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "ClimaCell - Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 24 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 52.625 - assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1028.1246 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1028.12 assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 6.6 - assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.9940 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.99 assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 320.31 - assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.6289 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.63 assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758 assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index 427645fb871..7ed604495dc 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -15,7 +15,7 @@ from homeassistant.components.climate.const import ( ATTR_SWING_MODES, ATTR_TARGET_TEMP_STEP, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 465ff7dd3d4..4e0df3c8ee3 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -9,7 +9,6 @@ from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import EntityCategory -from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed, mock_registry @@ -270,10 +269,7 @@ async def test_alexa_config_fail_refresh_token( @contextlib.contextmanager def patch_sync_helper(): - """Patch sync helper. - - In Py3.7 this would have been an async context manager. - """ + """Patch sync helper.""" to_update = [] to_remove = [] @@ -291,21 +287,32 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to updating exposed entities.""" + hass.states.async_set("binary_sensor.door", "on") + hass.states.async_set( + "sensor.temp", + "23", + {"device_class": "temperature", "unit_of_measurement": "°C"}, + ) + hass.states.async_set("light.kitchen", "off") + await cloud_prefs.async_update( + alexa_enabled=True, alexa_report_state=False, ) - await alexa_config.CloudAlexaConfig( + conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ).async_initialize() + ) + await conf.async_initialize() with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update_alexa_entity_config( entity_id="light.kitchen", should_expose=True ) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow()) + async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() + assert conf._alexa_sync_unsub is None assert to_update == ["light.kitchen"] assert to_remove == [] @@ -320,12 +327,23 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): entity_id="sensor.temp", should_expose=True ) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow()) + async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() + assert conf._alexa_sync_unsub is None assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"] assert to_remove == ["light.kitchen"] + with patch_sync_helper() as (to_update, to_remove): + await cloud_prefs.async_update( + alexa_enabled=False, + ) + await hass.async_block_till_done() + + assert conf._alexa_sync_unsub is None + assert to_update == [] + assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"] + async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry.""" diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index f56a1c86d4d..c125f5c252a 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -134,7 +134,16 @@ async def test_handler_google_actions(hass): assert device["roomHint"] == "living room" -async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): +@pytest.mark.parametrize( + "intent,response_payload", + [ + ("action.devices.SYNC", {"agentUserId": "myUserName", "devices": []}), + ("action.devices.QUERY", {"errorCode": "deviceTurnedOff"}), + ], +) +async def test_handler_google_actions_disabled( + hass, mock_cloud_fixture, intent, response_payload +): """Test handler Google Actions when user has disabled it.""" mock_cloud_fixture._prefs[PREF_ENABLE_GOOGLE] = False @@ -142,13 +151,17 @@ async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): assert await async_setup_component(hass, "cloud", {}) reqid = "5711642932632160983" - data = {"requestId": reqid, "inputs": [{"intent": "action.devices.SYNC"}]} + data = {"requestId": reqid, "inputs": [{"intent": intent}]} cloud = hass.data["cloud"] - resp = await cloud.client.async_google_message(data) + with patch( + "hass_nabucasa.Cloud._decode_claims", + return_value={"cognito:username": "myUserName"}, + ): + resp = await cloud.client.async_google_message(data) assert resp["requestId"] == reqid - assert resp["payload"]["errorCode"] == "deviceTurnedOff" + assert resp["payload"] == response_payload async def test_webhook_msg(hass, caplog): diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 65741fd86ba..6d504d03b6a 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -163,7 +163,7 @@ async def test_numpy_errors(hass, caplog): await hass.async_start() await hass.async_block_till_done() - assert "invalid value encountered in true_divide" in caplog.text + assert "invalid value encountered in divide" in caplog.text async def test_datapoints_greater_than_degree(hass, caplog): diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bb0d67fc306..611a7f75939 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -2,7 +2,7 @@ from collections import OrderedDict from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import ANY, AsyncMock, patch import pytest import voluptuous as vol @@ -13,6 +13,7 @@ from homeassistant.config_entries import HANDLERS, ConfigFlow from homeassistant.core import callback from homeassistant.generated import config_flows from homeassistant.helpers import config_entry_flow, config_validation as cv +from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from tests.common import ( @@ -1113,3 +1114,192 @@ async def test_ignore_flow_nonexisting(hass, hass_ws_client): assert not response["success"] assert response["error"]["code"] == "not_found" + + +async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): + """Test get entries with the websocket api.""" + assert await async_setup_component(hass, "config", {}) + mock_integration(hass, MockModule("comp1")) + mock_integration( + hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) + ) + mock_integration(hass, MockModule("comp3")) + entry = MockConfigEntry( + domain="comp1", + title="Test 1", + source="bla", + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain="comp2", + title="Test 2", + source="bla2", + state=core_ce.ConfigEntryState.SETUP_ERROR, + reason="Unsupported API", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp3", + title="Test 3", + source="bla3", + disabled_by=core_ce.ConfigEntryDisabler.USER, + ).add_to_hass(hass) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/get", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + { + "disabled_by": None, + "domain": "comp2", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", + "source": "bla2", + "state": "setup_error", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 2", + }, + { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + ] + + await ws_client.send_json( + { + "id": 6, + "type": "config_entries/get", + "domain": "comp1", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 6 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + } + ] + # Verify we skip broken integrations + + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=IntegrationNotFound("any"), + ): + await ws_client.send_json( + { + "id": 7, + "type": "config_entries/get", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 7 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + { + "disabled_by": None, + "domain": "comp2", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", + "source": "bla2", + "state": "setup_error", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 2", + }, + { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + ] + + # Verify we raise if something really goes wrong + + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=Exception, + ): + await ws_client.send_json( + { + "id": 8, + "type": "config_entries/get", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 8 + assert response["success"] is False diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index e74e43de701..69744817a27 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -117,6 +117,7 @@ async def test_get_entity(hass, client): "entity_id": "test_domain.name", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": "Hello World", "options": {}, "original_device_class": None, @@ -146,6 +147,7 @@ async def test_get_entity(hass, client): "entity_id": "test_domain.no_name", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, @@ -208,6 +210,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {}, "original_device_class": None, @@ -279,6 +282,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {}, "original_device_class": None, @@ -315,6 +319,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {"sensor": {"unit_of_measurement": "beard_second"}}, "original_device_class": None, @@ -373,6 +378,7 @@ async def test_update_entity_require_restart(hass, client): "entity_id": "test_domain.world", "icon": None, "hidden_by": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, @@ -479,6 +485,7 @@ async def test_update_entity_no_changes(hass, client): "entity_id": "test_domain.world", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": "name of entity", "options": {}, "original_device_class": None, @@ -564,6 +571,7 @@ async def test_update_entity_id(hass, client): "entity_id": "test_domain.planet", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index 5c9c192a0aa..213ce3b2e08 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -2,19 +2,7 @@ from unittest.mock import patch -from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY, - ANCILLARY_CONTROL_ARMING_AWAY, - ANCILLARY_CONTROL_ARMING_NIGHT, - ANCILLARY_CONTROL_ARMING_STAY, - ANCILLARY_CONTROL_DISARMED, - ANCILLARY_CONTROL_ENTRY_DELAY, - ANCILLARY_CONTROL_EXIT_DELAY, - ANCILLARY_CONTROL_IN_ALARM, - ANCILLARY_CONTROL_NOT_READY, -) +from pydeconz.models.sensor.ancillary_control import AncillaryControlPanel from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, @@ -123,7 +111,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"panel": AncillaryControlPanel.ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -137,7 +125,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_ARMED_NIGHT}, + "state": {"panel": AncillaryControlPanel.ARMED_NIGHT}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -153,7 +141,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_ARMED_STAY}, + "state": {"panel": AncillaryControlPanel.ARMED_STAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -167,7 +155,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_DISARMED}, + "state": {"panel": AncillaryControlPanel.DISARMED}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -177,9 +165,9 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): # Event signals alarm control panel arming for arming_event in { - ANCILLARY_CONTROL_ARMING_AWAY, - ANCILLARY_CONTROL_ARMING_NIGHT, - ANCILLARY_CONTROL_ARMING_STAY, + AncillaryControlPanel.ARMING_AWAY, + AncillaryControlPanel.ARMING_NIGHT, + AncillaryControlPanel.ARMING_STAY, }: event_changed_sensor = { @@ -196,7 +184,10 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): # Event signals alarm control panel pending - for pending_event in {ANCILLARY_CONTROL_ENTRY_DELAY, ANCILLARY_CONTROL_EXIT_DELAY}: + for pending_event in { + AncillaryControlPanel.ENTRY_DELAY, + AncillaryControlPanel.EXIT_DELAY, + }: event_changed_sensor = { "t": "event", @@ -219,7 +210,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_IN_ALARM}, + "state": {"panel": AncillaryControlPanel.IN_ALARM}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -233,7 +224,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_NOT_READY}, + "state": {"panel": AncillaryControlPanel.NOT_READY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index b6a21fb9fa6..ae62205c636 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass from homeassistant.components.deconz.const import ( CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_NEW_DEVICES, @@ -63,7 +63,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.alarm_10", - "unique_id": "00:15:8d:00:02:b5:d1:80-01-0500", + "unique_id": "00:15:8d:00:02:b5:d1:80-01-0500-alarm", + "old_unique_id": "00:15:8d:00:02:b5:d1:80-01-0500", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.SAFETY, @@ -104,7 +105,8 @@ TEST_DATA = [ "entity_count": 4, "device_count": 3, "entity_id": "binary_sensor.cave_co", - "unique_id": "00:15:8d:00:02:a5:21:24-01-0101", + "unique_id": "00:15:8d:00:02:a5:21:24-01-0101-carbon_monoxide", + "old_unique_id": "00:15:8d:00:02:a5:21:24-01-0101", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.CO, @@ -139,7 +141,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "binary_sensor.sensor_kitchen_smoke", - "unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500", + "unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500-fire", + "old_unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.SMOKE, @@ -175,7 +178,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "binary_sensor.sensor_kitchen_smoke_test_mode", - "unique_id": "00:15:8d:00:01:d9:3e:7c-test mode", + "unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500-in_test_mode", + "old_unique_id": "00:15:8d:00:01:d9:3e:7c-test mode", "state": STATE_OFF, "entity_category": EntityCategory.DIAGNOSTIC, "device_class": BinarySensorDeviceClass.SMOKE, @@ -207,7 +211,8 @@ TEST_DATA = [ "entity_count": 1, "device_count": 2, "entity_id": "binary_sensor.kitchen_switch", - "unique_id": "kitchen-switch", + "unique_id": "kitchen-switch-flag", + "old_unique_id": "kitchen-switch", "state": STATE_ON, "entity_category": None, "device_class": None, @@ -244,7 +249,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.back_door", - "unique_id": "00:15:8d:00:02:2b:96:b4-01-0006", + "unique_id": "00:15:8d:00:02:2b:96:b4-01-0006-open", + "old_unique_id": "00:15:8d:00:02:2b:96:b4-01-0006", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.OPENING, @@ -290,7 +296,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.motion_sensor_4", - "unique_id": "00:17:88:01:03:28:8c:9b-02-0406", + "unique_id": "00:17:88:01:03:28:8c:9b-02-0406-presence", + "old_unique_id": "00:17:88:01:03:28:8c:9b-02-0406", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.MOTION, @@ -331,7 +338,8 @@ TEST_DATA = [ "entity_count": 5, "device_count": 3, "entity_id": "binary_sensor.water2", - "unique_id": "00:15:8d:00:02:2f:07:db-01-0500", + "unique_id": "00:15:8d:00:02:2f:07:db-01-0500-water", + "old_unique_id": "00:15:8d:00:02:2f:07:db-01-0500", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.MOISTURE, @@ -376,7 +384,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.vibration_1", - "unique_id": "00:15:8d:00:02:a5:21:24-01-0101", + "unique_id": "00:15:8d:00:02:a5:21:24-01-0101-vibration", + "old_unique_id": "00:15:8d:00:02:a5:21:24-01-0101", "state": STATE_ON, "entity_category": None, "device_class": BinarySensorDeviceClass.VIBRATION, @@ -414,7 +423,8 @@ TEST_DATA = [ "entity_count": 4, "device_count": 3, "entity_id": "binary_sensor.presence_sensor_tampered", - "unique_id": "00:00:00:00:00:00:00:00-tampered", + "unique_id": "00:00:00:00:00:00:00:00-00-tampered", + "old_unique_id": "00:00:00:00:00:00:00:00-tampered", "state": STATE_OFF, "entity_category": EntityCategory.DIAGNOSTIC, "device_class": BinarySensorDeviceClass.TAMPER, @@ -447,7 +457,8 @@ TEST_DATA = [ "entity_count": 4, "device_count": 3, "entity_id": "binary_sensor.presence_sensor_low_battery", - "unique_id": "00:00:00:00:00:00:00:00-low battery", + "unique_id": "00:00:00:00:00:00:00:00-00-low_battery", + "old_unique_id": "00:00:00:00:00:00:00:00-low battery", "state": STATE_OFF, "entity_category": EntityCategory.DIAGNOSTIC, "device_class": BinarySensorDeviceClass.BATTERY, @@ -470,6 +481,14 @@ async def test_binary_sensors( ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) + # Create entity entry to migrate to new unique ID + ent_reg.async_get_or_create( + DOMAIN, + DECONZ_DOMAIN, + expected["old_unique_id"], + suggested_object_id=expected["entity_id"].replace(DOMAIN, ""), + ) + with patch.dict(DECONZ_WEB_REQUEST, {"sensors": {"1": sensor_data}}): config_entry = await setup_deconz_integration( hass, aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True} diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index e697edd5a9a..c326892aef2 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -3,11 +3,8 @@ from unittest.mock import patch from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_EMERGENCY, - ANCILLARY_CONTROL_FIRE, - ANCILLARY_CONTROL_INVALID_CODE, - ANCILLARY_CONTROL_PANIC, + AncillaryControlAction, + AncillaryControlPanel, ) from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN @@ -286,7 +283,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_EMERGENCY}, + "state": {"action": AncillaryControlAction.EMERGENCY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -300,7 +297,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_EMERGENCY, + CONF_EVENT: AncillaryControlAction.EMERGENCY.value, } # Fire event @@ -310,7 +307,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_FIRE}, + "state": {"action": AncillaryControlAction.FIRE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -324,7 +321,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_FIRE, + CONF_EVENT: AncillaryControlAction.FIRE.value, } # Invalid code event @@ -334,7 +331,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_INVALID_CODE}, + "state": {"action": AncillaryControlAction.INVALID_CODE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -348,7 +345,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_INVALID_CODE, + CONF_EVENT: AncillaryControlAction.INVALID_CODE.value, } # Panic event @@ -358,7 +355,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_PANIC}, + "state": {"action": AncillaryControlAction.PANIC}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -372,7 +369,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_PANIC, + CONF_EVENT: AncillaryControlAction.PANIC.value, } # Only care for changes to specific action events @@ -382,7 +379,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"action": AncillaryControlAction.ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -396,7 +393,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"panel": AncillaryControlPanel.ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -415,7 +412,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): assert len(hass.states.async_all()) == 0 -async def test_deconz_events_bad_unique_id(hass, aioclient_mock, mock_deconz_websocket): +async def test_deconz_events_bad_unique_id(hass, aioclient_mock): """Verify no devices are created if unique id is bad or missing.""" data = { "sensors": { diff --git a/tests/components/demo/test_weather.py b/tests/components/demo/test_weather.py index db3f3441df1..8c93219f8e6 100644 --- a/tests/components/demo/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -36,7 +36,7 @@ async def test_attributes(hass): assert data.get(ATTR_WEATHER_TEMPERATURE) == 21.6 assert data.get(ATTR_WEATHER_HUMIDITY) == 92 assert data.get(ATTR_WEATHER_PRESSURE) == 1099 - assert data.get(ATTR_WEATHER_WIND_SPEED) == 0.5 + assert data.get(ATTR_WEATHER_WIND_SPEED) == 1.8 # 0.5 m/s -> km/h assert data.get(ATTR_WEATHER_WIND_BEARING) is None assert data.get(ATTR_WEATHER_OZONE) is None assert data.get(ATTR_ATTRIBUTION) == "Powered by Home Assistant" @@ -53,20 +53,3 @@ async def test_attributes(hass): data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 100 ) assert len(data.get(ATTR_FORECAST)) == 7 - - -async def test_temperature_convert(hass): - """Test temperature conversion.""" - assert await async_setup_component( - hass, weather.DOMAIN, {"weather": {"platform": "demo"}} - ) - hass.config.units = METRIC_SYSTEM - await hass.async_block_till_done() - - state = hass.states.get("weather.demo_weather_north") - assert state is not None - - assert state.state == "rainy" - - data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == -24.4 diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index b43cb77ad71..e9dae0b70b1 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -51,7 +51,6 @@ class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): self.element_uid = "Test" self.min = 4 self.max = 24 - self.switch_type = "temperature" self._value = 20 self._logger = MagicMock() @@ -120,9 +119,21 @@ class ClimateMock(DeviceMock): super().__init__() self.device_model_uid = "devolo.model.Room:Thermostat" self.multi_level_switch_property = {"Test": MultiLevelSwitchPropertyMock()} + self.multi_level_switch_property["Test"].switch_type = "temperature" self.multi_level_sensor_property = {"Test": MultiLevelSensorPropertyMock()} +class CoverMock(DeviceMock): + """devolo Home Control cover device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.multi_level_switch_property = { + "devolo.Blinds": MultiLevelSwitchPropertyMock() + } + + class RemoteControlMock(DeviceMock): """devolo Home Control remote control device mock.""" @@ -195,6 +206,19 @@ class HomeControlMockClimate(HomeControlMock): self.publisher.unregister = MagicMock() +class HomeControlMockCover(HomeControlMock): + """devolo Home Control gateway mock with cover devices.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = { + "Test": CoverMock(), + } + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() + + class HomeControlMockRemoteControl(HomeControlMock): """devolo Home Control gateway mock with remote control device.""" diff --git a/tests/components/devolo_home_control/test_cover.py b/tests/components/devolo_home_control/test_cover.py new file mode 100644 index 00000000000..1c05c00370b --- /dev/null +++ b/tests/components/devolo_home_control/test_cover.py @@ -0,0 +1,103 @@ +"""Tests for the devolo Home Control cover platform.""" +from unittest.mock import patch + +from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + STATE_CLOSED, + STATE_OPEN, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockCover + + +async def test_cover(hass: HomeAssistant): + """Test setup and state change of a cover device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockCover() + test_gateway.devices["Test"].multi_level_switch_property["devolo.Blinds"].value = 20 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OPEN + assert ( + state.attributes[ATTR_CURRENT_POSITION] + == test_gateway.devices["Test"] + .multi_level_switch_property["devolo.Blinds"] + .value + ) + + # Emulate websocket message: position changed + test_gateway.publisher.dispatch("Test", ("devolo.Blinds", 0.0)) + await hass.async_block_till_done() + state = hass.states.get(f"{DOMAIN}.test") + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0.0 + + # Test setting position + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set_value: + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(100) + + set_value.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(0) + + set_value.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: f"{DOMAIN}.test", ATTR_POSITION: 50}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(50) + + # Emulate websocket message: device went offline + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + + +async def test_remove_from_hass(hass: HomeAssistant): + """Test removing entity.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockCover() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert test_gateway.publisher.unregister.call_count == 1 diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 66d32e8974d..4f0c5b3fb58 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -58,4 +58,5 @@ async def test_hass_stop(hass: HomeAssistant): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - assert async_disconnect.assert_called_once + await hass.async_block_till_done() + async_disconnect.assert_called_once() diff --git a/tests/components/discord/test_config_flow.py b/tests/components/discord/test_config_flow.py index 59030187866..9d4966929be 100644 --- a/tests/components/discord/test_config_flow.py +++ b/tests/components/discord/test_config_flow.py @@ -128,14 +128,9 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: "entry_id": entry.entry_id, "unique_id": entry.unique_id, }, + data=entry.data, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={}, - ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "reauth_confirm" diff --git a/tests/components/eight_sleep/__init__.py b/tests/components/eight_sleep/__init__.py new file mode 100644 index 00000000000..22348f774be --- /dev/null +++ b/tests/components/eight_sleep/__init__.py @@ -0,0 +1 @@ +"""Tests for the Eight Sleep integration.""" diff --git a/tests/components/eight_sleep/conftest.py b/tests/components/eight_sleep/conftest.py new file mode 100644 index 00000000000..753fe1e30d5 --- /dev/null +++ b/tests/components/eight_sleep/conftest.py @@ -0,0 +1,29 @@ +"""Fixtures for Eight Sleep.""" +from unittest.mock import patch + +from pyeight.exceptions import RequestError +import pytest + + +@pytest.fixture(name="bypass", autouse=True) +def bypass_fixture(): + """Bypasses things that slow te tests down or block them from testing the behavior.""" + with patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", + ), patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.at_exit", + ), patch( + "homeassistant.components.eight_sleep.async_setup_entry", + return_value=True, + ): + yield + + +@pytest.fixture(name="token_error") +def token_error_fixture(): + """Simulate error when fetching token.""" + with patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", + side_effect=RequestError, + ): + yield diff --git a/tests/components/eight_sleep/test_config_flow.py b/tests/components/eight_sleep/test_config_flow.py new file mode 100644 index 00000000000..8015fb6c69d --- /dev/null +++ b/tests/components/eight_sleep/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Eight Sleep config flow.""" +from homeassistant import config_entries +from homeassistant.components.eight_sleep.const import DOMAIN +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +async def test_form(hass) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + + +async def test_form_invalid_auth(hass, token_error) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "bad-username", + "password": "bad-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_import(hass) -> None: + """Test import works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + } + + +async def test_import_invalid_auth(hass, token_error) -> None: + """Test we handle invalid auth on import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "bad-username", + "password": "bad-password", + }, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index efae0739c7b..b0d5415110d 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -37,6 +37,16 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup +@pytest.fixture +def mock_onboarding() -> Generator[None, MagicMock, None]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding + + @pytest.fixture def mock_elgato_config_flow() -> Generator[None, MagicMock, None]: """Return a mocked Elgato client.""" diff --git a/tests/components/elgato/test_button.py b/tests/components/elgato/test_button.py index 6f182ee191c..2ab3d7ee7c4 100644 --- a/tests/components/elgato/test_button.py +++ b/tests/components/elgato/test_button.py @@ -8,6 +8,7 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRE from homeassistant.components.elgato.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory @@ -68,17 +69,19 @@ async def test_button_identify_error( hass: HomeAssistant, init_integration: MockConfigEntry, mock_elgato: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test an error occurs with the Elgato identify button.""" mock_elgato.identify.side_effect = ElgatoError - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.identify"}, - blocking=True, - ) - await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, match="An error occurred while identifying the Elgato Light" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.identify"}, + blocking=True, + ) + await hass.async_block_till_done() assert len(mock_elgato.identify.mock_calls) == 1 - assert "An error occurred while identifying the Elgato Light" in caplog.text diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index fdc0ad834d1..0e3916a005e 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -204,3 +204,39 @@ async def test_zeroconf_device_exists_abort( entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].data[CONF_HOST] == "127.0.0.2" + + +async def test_zeroconf_during_onboarding( + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, +) -> None: + """Test the zeroconf creates an entry during onboarding.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + addresses=["127.0.0.1"], + hostname="example.local.", + name="mock_name", + port=9123, + properties={"id": "AA:BB:CC:DD:EE:FF"}, + type="mock_type", + ), + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "CN11A1A00001" + assert result.get("data") == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_PORT: 9123, + } + assert "result" in result + assert result["result"].unique_id == "CN11A1A00001" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_elgato_config_flow.info.mock_calls) == 1 + assert len(mock_onboarding.mock_calls) == 1 diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 743abc1ad49..9cd4f9bd326 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -183,13 +184,15 @@ async def test_light_unavailable( mock_elgato.state.side_effect = ElgatoError mock_elgato.light.side_effect = ElgatoError - await hass.services.async_call( - LIGHT_DOMAIN, - service, - {ATTR_ENTITY_ID: "light.frenck"}, - blocking=True, - ) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.frenck"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.frenck") assert state assert state.state == STATE_UNAVAILABLE @@ -218,18 +221,20 @@ async def test_light_identify_error( hass: HomeAssistant, init_integration: MockConfigEntry, mock_elgato: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test error occurred during identifying an Elgato Light.""" mock_elgato.identify.side_effect = ElgatoError - await hass.services.async_call( - DOMAIN, - SERVICE_IDENTIFY, - { - ATTR_ENTITY_ID: "light.frenck", - }, - blocking=True, - ) - await hass.async_block_till_done() + with pytest.raises( + HomeAssistantError, match="An error occurred while identifying the Elgato Light" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_IDENTIFY, + { + ATTR_ENTITY_ID: "light.frenck", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.identify.mock_calls) == 1 - assert "An error occurred while identifying the Elgato Light" in caplog.text diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index dd27eed9771..e36903983fe 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -49,7 +49,8 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from tests.common import ( @@ -96,39 +97,58 @@ ENTITY_IDS_BY_NUMBER = { ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} -@pytest.fixture -def hass_hue(loop, hass): - """Set up a Home Assistant instance for these tests.""" - # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(setup.async_setup_component(hass, "homeassistant", {})) +def patch_upnp(): + """Patch async_create_upnp_datagram_endpoint.""" + return patch( + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" + ) - loop.run_until_complete( + +async def async_get_lights(client): + """Get lights with the hue client.""" + result = await client.get("/api/username/lights") + assert result.status == HTTPStatus.OK + assert CONTENT_TYPE_JSON in result.headers["content-type"] + return await result.json() + + +async def _async_setup_emulated_hue(hass: HomeAssistant, conf: ConfigType) -> None: + """Set up emulated_hue with a specific config.""" + with patch_upnp(): + await setup.async_setup_component( + hass, + emulated_hue.DOMAIN, + {emulated_hue.DOMAIN: conf}, + ), + await hass.async_block_till_done() + + +@pytest.fixture +async def base_setup(hass): + """Set up homeassistant and http.""" + await asyncio.gather( + setup.async_setup_component(hass, "homeassistant", {}), setup.async_setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}} - ) + ), ) - with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"): - loop.run_until_complete( - setup.async_setup_component( - hass, - emulated_hue.DOMAIN, - { - emulated_hue.DOMAIN: { - emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, - emulated_hue.CONF_EXPOSE_BY_DEFAULT: True, - } - }, - ) - ) - loop.run_until_complete( +@pytest.fixture +async def demo_setup(hass): + """Fixture to setup demo platforms.""" + # We need to do this to get access to homeassistant/turn_(on,off) + setups = [ + setup.async_setup_component(hass, "homeassistant", {}), setup.async_setup_component( - hass, light.DOMAIN, {"light": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( + hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}} + ), + *[ + setup.async_setup_component( + hass, comp.DOMAIN, {comp.DOMAIN: [{"platform": "demo"}]} + ) + for comp in (light, climate, humidifier, media_player, fan, cover) + ], setup.async_setup_component( hass, script.DOMAIN, @@ -147,39 +167,7 @@ def hass_hue(loop, hass): } } }, - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, humidifier.DOMAIN, {"humidifier": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, media_player.DOMAIN, {"media_player": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component(hass, fan.DOMAIN, {"fan": [{"platform": "demo"}]}) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, cover.DOMAIN, {"cover": [{"platform": "demo"}]} - ) - ) - - # setup a dummy scene - loop.run_until_complete( + ), setup.async_setup_component( hass, "scene", @@ -197,21 +185,49 @@ def hass_hue(loop, hass): }, ] }, - ) - ) + ), + ] - # create a lamp without brightness support - hass.states.async_set("light.no_brightness", "on", {}) - - return hass + await asyncio.gather(*setups) @pytest.fixture -def hue_client(loop, hass_hue, hass_client_no_auth): +async def hass_hue(hass, base_setup, demo_setup): + """Set up a Home Assistant instance for these tests.""" + await _async_setup_emulated_hue( + hass, + { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: True, + }, + ) + # create a lamp without brightness support + hass.states.async_set("light.no_brightness", "on", {}) + return hass + + +@callback +def _mock_hue_endpoints( + hass: HomeAssistant, conf: ConfigType, entity_numbers: dict[str, str] +) -> None: + """Override the hue config with specific entity numbers.""" + web_app = hass.http.app + config = Config(hass, conf, "127.0.0.1") + config.numbers = entity_numbers + HueUsernameView().register(web_app, web_app.router) + HueAllLightsStateView(config).register(web_app, web_app.router) + HueOneLightStateView(config).register(web_app, web_app.router) + HueOneLightChangeView(config).register(web_app, web_app.router) + HueAllGroupsStateView(config).register(web_app, web_app.router) + HueFullStateView(config).register(web_app, web_app.router) + HueConfigView(config).register(web_app, web_app.router) + + +@pytest.fixture +async def hue_client(hass_hue, hass_client_no_auth): """Create web client for emulated hue api.""" - web_app = hass_hue.http.app - config = Config( - None, + _mock_hue_endpoints( + hass_hue, { emulated_hue.CONF_ENTITIES: { "light.bed_light": {emulated_hue.CONF_ENTITY_HIDDEN: True}, @@ -242,22 +258,12 @@ def hue_client(loop, hass_hue, hass_client_no_auth): "scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False}, }, }, - "127.0.0.1", + ENTITY_IDS_BY_NUMBER, ) - config.numbers = ENTITY_IDS_BY_NUMBER - - HueUsernameView().register(web_app, web_app.router) - HueAllLightsStateView(config).register(web_app, web_app.router) - HueOneLightStateView(config).register(web_app, web_app.router) - HueOneLightChangeView(config).register(web_app, web_app.router) - HueAllGroupsStateView(config).register(web_app, web_app.router) - HueFullStateView(config).register(web_app, web_app.router) - HueConfigView(config).register(web_app, web_app.router) - - return loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() -async def test_discover_lights(hue_client): +async def test_discover_lights(hass, hue_client): """Test the discovery of lights.""" result = await hue_client.get("/api/username/lights") @@ -290,6 +296,21 @@ async def test_discover_lights(hue_client): assert "00:62:5c:3e:df:58:40:01-43" in devices # scene.light_on assert "00:1c:72:08:ed:09:e7:89-77" in devices # scene.light_off + # Remove the state and ensure it disappears from devices + hass.states.async_remove("light.ceiling_lights") + await hass.async_block_till_done() + + result_json = await async_get_lights(hue_client) + devices = {val["uniqueid"] for val in result_json.values()} + assert "00:2f:d2:31:ce:c5:55:cc-ee" not in devices # light.ceiling_lights + + # Restore the state and ensure it reappears in devices + hass.states.async_set("light.ceiling_lights", STATE_ON) + await hass.async_block_till_done() + result_json = await async_get_lights(hue_client) + devices = {val["uniqueid"] for val in result_json.values()} + assert "00:2f:d2:31:ce:c5:55:cc-ee" in devices # light.ceiling_lights + async def test_light_without_brightness_supported(hass_hue, hue_client): """Test that light without brightness is supported.""" @@ -314,17 +335,8 @@ async def test_lights_all_dimmable(hass, hass_client_no_auth): emulated_hue.CONF_EXPOSE_BY_DEFAULT: True, emulated_hue.CONF_LIGHTS_ALL_DIMMABLE: True, } - with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"): - await setup.async_setup_component( - hass, - emulated_hue.DOMAIN, - {emulated_hue.DOMAIN: hue_config}, - ) - await hass.async_block_till_done() - config = Config(None, hue_config, "127.0.0.1") - config.numbers = ENTITY_IDS_BY_NUMBER - web_app = hass.http.app - HueOneLightStateView(config).register(web_app, web_app.router) + await _async_setup_emulated_hue(hass, hue_config) + _mock_hue_endpoints(hass, hue_config, ENTITY_IDS_BY_NUMBER) client = await hass_client_no_auth() light_without_brightness_json = await perform_get_light_state( client, "light.no_brightness", HTTPStatus.OK @@ -564,13 +576,7 @@ async def test_get_light_state(hass_hue, hue_client): assert office_json["state"][HUE_API_STATE_SAT] == 217 # Check all lights view - result = await hue_client.get("/api/username/lights") - - assert result.status == HTTPStatus.OK - assert CONTENT_TYPE_JSON in result.headers["content-type"] - - result_json = await result.json() - + result_json = await async_get_lights(hue_client) assert ENTITY_NUMBERS_BY_ID["light.ceiling_lights"] in result_json assert ( result_json[ENTITY_NUMBERS_BY_ID["light.ceiling_lights"]]["state"][ @@ -1612,3 +1618,32 @@ async def test_only_change_hue_or_saturation(hass, hass_hue, hue_client): assert hass_hue.states.get("light.ceiling_lights").attributes[ light.ATTR_HS_COLOR ] == (0, 3) + + +async def test_specificly_exposed_entities(hass, base_setup, hass_client_no_auth): + """Test specific entities with expose by default off.""" + conf = { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: False, + emulated_hue.CONF_ENTITIES: { + "light.exposed": {emulated_hue.CONF_ENTITY_HIDDEN: False}, + }, + } + await _async_setup_emulated_hue(hass, conf) + _mock_hue_endpoints(hass, conf, {"1": "light.exposed"}) + hass.states.async_set("light.exposed", STATE_ON) + await hass.async_block_till_done() + client = await hass_client_no_auth() + result_json = await async_get_lights(client) + assert "1" in result_json + + hass.states.async_remove("light.exposed") + await hass.async_block_till_done() + result_json = await async_get_lights(client) + assert "1" not in result_json + + hass.states.async_set("light.exposed", STATE_ON) + await hass.async_block_till_done() + result_json = await async_get_lights(client) + + assert "1" in result_json diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 93bf8c0631f..408a44cda00 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,13 +1,14 @@ """Test the Emulated Hue component.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from homeassistant.components.emulated_hue import ( +from homeassistant.components.emulated_hue.config import ( DATA_KEY, DATA_VERSION, SAVE_DELAY, Config, ) +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component from homeassistant.util import utcnow @@ -121,6 +122,13 @@ async def test_setup_works(hass): """Test setup works.""" hass.config.components.add("network") with patch( - "homeassistant.components.emulated_hue.create_upnp_datagram_endpoint" - ), patch("homeassistant.components.emulated_hue.async_get_source_ip"): + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint", + AsyncMock(), + ) as mock_create_upnp_datagram_endpoint, patch( + "homeassistant.components.emulated_hue.async_get_source_ip" + ): assert await async_setup_component(hass, "emulated_hue", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert len(mock_create_upnp_datagram_endpoint.mock_calls) == 1 diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index ec04ee7e19c..ce7f013963c 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -2,6 +2,7 @@ from http import HTTPStatus import json import unittest +from unittest.mock import patch from aiohttp import web import defusedxml.ElementTree as ET @@ -52,11 +53,15 @@ def hue_client(aiohttp_client): async def setup_hue(hass): """Set up the emulated_hue integration.""" - assert await setup.async_setup_component( - hass, - emulated_hue.DOMAIN, - {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, - ) + with patch( + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" + ): + assert await setup.async_setup_component( + hass, + emulated_hue.DOMAIN, + {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, + ) + await hass.async_block_till_done() def test_upnp_discovery_basic(): @@ -157,7 +162,7 @@ async def test_description_xml(hass, hue_client): root = ET.fromstring(await result.text()) ns = {"s": "urn:schemas-upnp-org:device-1-0"} assert root.find("./s:device/s:serialNumber", ns).text == "001788FFFE23BFC2" - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except pytest.fail("description.xml is not valid XML!") diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 37ebe4147c5..fe71663d41b 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -4,6 +4,12 @@ from unittest.mock import patch import pytest from homeassistant.components.energy import async_get_manager, validate +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, +) +from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component @@ -59,16 +65,18 @@ async def test_validation_empty_config(hass): @pytest.mark.parametrize( - "state_class, extra", + "state_class, energy_unit, extra", [ - ("total_increasing", {}), - ("total", {}), - ("total", {"last_reset": "abc"}), - ("measurement", {"last_reset": "abc"}), + ("total_increasing", ENERGY_KILO_WATT_HOUR, {}), + ("total_increasing", ENERGY_MEGA_WATT_HOUR, {}), + ("total_increasing", ENERGY_WATT_HOUR, {}), + ("total", ENERGY_KILO_WATT_HOUR, {}), + ("total", ENERGY_KILO_WATT_HOUR, {"last_reset": "abc"}), + ("measurement", ENERGY_KILO_WATT_HOUR, {"last_reset": "abc"}), ], ) async def test_validation( - hass, mock_energy_manager, mock_get_metadata, state_class, extra + hass, mock_energy_manager, mock_get_metadata, state_class, energy_unit, extra ): """Test validating success.""" for key in ("device_cons", "battery_import", "battery_export", "solar_production"): @@ -77,7 +85,7 @@ async def test_validation( "123", { "device_class": "energy", - "unit_of_measurement": "kWh", + "unit_of_measurement": energy_unit, "state_class": state_class, **extra, }, @@ -408,7 +416,11 @@ async def test_validation_grid( }, ) - assert (await validate.async_validate(hass)).as_dict() == { + result = await validate.async_validate(hass) + # verify its also json serializable + JSON_DUMP(result) + + assert result.as_dict() == { "energy_sources": [ [ { diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f7da5d66bd5..1d2cff051ae 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -12,7 +12,7 @@ from aioesphomeapi import ( import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf +from homeassistant.components import dhcp, zeroconf from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, DomainData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import ( @@ -532,3 +532,61 @@ async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf): assert result["step_id"] == "reauth_confirm" assert result["errors"] assert result["errors"]["base"] == "invalid_psk" + + +async def test_discovery_dhcp_updates_host(hass, mock_client): + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + mock_entry_data = MagicMock() + mock_entry_data.device_info.name = "test8266" + domain_data = DomainData.get(hass) + domain_data.set_entry_data(entry, mock_entry_data) + + service_info = dhcp.DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="00:00:00:00:00:00", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + assert entry.unique_id == "test8266" + assert entry.data[CONF_HOST] == "192.168.43.184" + + +async def test_discovery_dhcp_no_changes(hass, mock_client): + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + mock_entry_data = MagicMock() + mock_entry_data.device_info.name = "test8266" + domain_data = DomainData.get(hass) + domain_data.set_entry_data(entry, mock_entry_data) + + service_info = dhcp.DhcpServiceInfo( + ip="192.168.43.183", + hostname="test8266", + macaddress="00:00:00:00:00:00", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + assert entry.unique_id == "test8266" + assert entry.data[CONF_HOST] == "192.168.43.183" diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index aa5bae45f4c..604f5e3a2e9 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import fan from homeassistant.components.fan import ATTR_PRESET_MODES -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 6f3e035a2f7..f056f484a58 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -53,7 +53,12 @@ async def test_config_flow_user_initiated_success(hass): login_mock = Mock() login_mock.get.return_value = Mock(status=True) - with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + with patch( + "fiblary3.client.v4.client.Client.login", login_mock, create=True + ), patch( + "homeassistant.components.fibaro.async_setup_entry", + return_value=True, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -182,7 +187,12 @@ async def test_config_flow_import(hass): """Test for importing config from configuration.yaml.""" login_mock = Mock() login_mock.get.return_value = Mock(status=True) - with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + with patch( + "fiblary3.client.v4.client.Client.login", login_mock, create=True + ), patch( + "homeassistant.components.fibaro.async_setup_entry", + return_value=True, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/file/fixtures/file_empty.txt b/tests/components/file/fixtures/file_empty.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/file/fixtures/file_value.txt b/tests/components/file/fixtures/file_value.txt new file mode 100644 index 00000000000..acfd8fbf1a9 --- /dev/null +++ b/tests/components/file/fixtures/file_value.txt @@ -0,0 +1,3 @@ +43 +45 +21 diff --git a/tests/components/file/fixtures/file_value_template.txt b/tests/components/file/fixtures/file_value_template.txt new file mode 100644 index 00000000000..30a1b0ea8ba --- /dev/null +++ b/tests/components/file/fixtures/file_value_template.txt @@ -0,0 +1,2 @@ +{"temperature": 29, "humidity": 31} +{"temperature": 26, "humidity": 36} diff --git a/tests/components/file/test_sensor.py b/tests/components/file/test_sensor.py index 97fe6250d02..725ccb527f8 100644 --- a/tests/components/file/test_sensor.py +++ b/tests/components/file/test_sensor.py @@ -1,5 +1,5 @@ """The tests for local file sensor platform.""" -from unittest.mock import Mock, mock_open, patch +from unittest.mock import Mock, patch import pytest @@ -7,7 +7,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import mock_registry +from tests.common import get_fixture_path, mock_registry @pytest.fixture @@ -21,13 +21,14 @@ def entity_reg(hass): async def test_file_value(hass: HomeAssistant) -> None: """Test the File sensor.""" config = { - "sensor": {"platform": "file", "name": "file1", "file_path": "mock.file1"} + "sensor": { + "platform": "file", + "name": "file1", + "file_path": get_fixture_path("file_value.txt", "file"), + } } - m_open = mock_open(read_data="43\n45\n21") - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=True): + with patch.object(hass.config, "is_allowed_path", return_value=True): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() @@ -43,19 +44,12 @@ async def test_file_value_template(hass: HomeAssistant) -> None: "sensor": { "platform": "file", "name": "file2", - "file_path": "mock.file2", + "file_path": get_fixture_path("file_value_template.txt", "file"), "value_template": "{{ value_json.temperature }}", } } - data = ( - '{"temperature": 29, "humidity": 31}\n' + '{"temperature": 26, "humidity": 36}' - ) - - m_open = mock_open(read_data=data) - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=True): + with patch.object(hass.config, "is_allowed_path", return_value=True): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() @@ -67,12 +61,15 @@ async def test_file_value_template(hass: HomeAssistant) -> None: @patch("os.access", Mock(return_value=True)) async def test_file_empty(hass: HomeAssistant) -> None: """Test the File sensor with an empty file.""" - config = {"sensor": {"platform": "file", "name": "file3", "file_path": "mock.file"}} + config = { + "sensor": { + "platform": "file", + "name": "file3", + "file_path": get_fixture_path("file_empty.txt", "file"), + } + } - m_open = mock_open(read_data="") - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=True): + with patch.object(hass.config, "is_allowed_path", return_value=True): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() @@ -85,13 +82,14 @@ async def test_file_empty(hass: HomeAssistant) -> None: async def test_file_path_invalid(hass: HomeAssistant) -> None: """Test the File sensor with invalid path.""" config = { - "sensor": {"platform": "file", "name": "file4", "file_path": "mock.file4"} + "sensor": { + "platform": "file", + "name": "file4", + "file_path": get_fixture_path("file_value.txt", "file"), + } } - m_open = mock_open(read_data="43\n45\n21") - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=False): + with patch.object(hass.config, "is_allowed_path", return_value=False): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index c5ab3dc1541..30468064dae 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch from flipr_api.exceptions import FliprError from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, @@ -62,30 +63,35 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "7.03" state = hass.states.get("sensor.flipr_myfliprid_water_temp") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is TEMP_CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "10.5" state = hass.states.get("sensor.flipr_myfliprid_last_measured") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.state == "2021-02-15T09:10:32+00:00" state = hass.states.get("sensor.flipr_myfliprid_red_ox") assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "657.58" state = hass.states.get("sensor.flipr_myfliprid_chlorine") assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "0.23654886" diff --git a/tests/components/folder_watcher/test_init.py b/tests/components/folder_watcher/test_init.py index b0a522cb7fc..babac930c2d 100644 --- a/tests/components/folder_watcher/test_init.py +++ b/tests/components/folder_watcher/test_init.py @@ -1,5 +1,6 @@ """The tests for the folder_watcher component.""" import os +from types import SimpleNamespace from unittest.mock import Mock, patch from homeassistant.components import folder_watcher @@ -43,7 +44,9 @@ def test_event(): hass = Mock() handler = folder_watcher.create_event_handler(["*"], hass) handler.on_created( - Mock(is_directory=False, src_path="/hello/world.txt", event_type="created") + SimpleNamespace( + is_directory=False, src_path="/hello/world.txt", event_type="created" + ) ) assert hass.bus.fire.called assert hass.bus.fire.mock_calls[0][1][0] == folder_watcher.DOMAIN @@ -53,3 +56,39 @@ def test_event(): "file": "world.txt", "folder": "/hello", } + + +def test_move_event(): + """Check that Home Assistant events are fired correctly on watchdog event.""" + + class MockPatternMatchingEventHandler: + """Mock base class for the pattern matcher event handler.""" + + def __init__(self, patterns): + pass + + with patch( + "homeassistant.components.folder_watcher.PatternMatchingEventHandler", + MockPatternMatchingEventHandler, + ): + hass = Mock() + handler = folder_watcher.create_event_handler(["*"], hass) + handler.on_moved( + SimpleNamespace( + is_directory=False, + src_path="/hello/world.txt", + dest_path="/hello/earth.txt", + event_type="moved", + ) + ) + assert hass.bus.fire.called + assert hass.bus.fire.mock_calls[0][1][0] == folder_watcher.DOMAIN + assert hass.bus.fire.mock_calls[0][1][1] == { + "event_type": "moved", + "path": "/hello/world.txt", + "dest_path": "/hello/earth.txt", + "file": "world.txt", + "dest_file": "earth.txt", + "folder": "/hello", + "dest_folder": "/hello", + } diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index dc5c545869b..808e858b259 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for the generic component.""" from io import BytesIO -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from PIL import Image import pytest import respx -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.generic.const import DOMAIN from tests.common import MockConfigEntry @@ -59,21 +59,26 @@ def fakeimg_gif(fakeimgbytes_gif): @pytest.fixture(scope="package") -def mock_av_open(): - """Fake container object with .streams.video[0] != None.""" - fake = Mock() - fake.streams.video = ["fakevid"] - return patch( - "homeassistant.components.generic.config_flow.av.open", - return_value=fake, +def mock_create_stream(): + """Mock create stream.""" + mock_stream = Mock() + mock_provider = Mock() + mock_provider.part_recv = AsyncMock() + mock_provider.part_recv.return_value = True + mock_stream.add_provider.return_value = mock_provider + mock_stream.start = AsyncMock() + mock_stream.stop = AsyncMock() + fake_create_stream = patch( + "homeassistant.components.generic.config_flow.create_stream", + return_value=mock_stream, ) + return fake_create_stream @pytest.fixture async def user_flow(hass): """Initiate a user flow.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 6e8b804f848..f7e1898f735 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -8,35 +8,46 @@ import httpx import pytest import respx -from homeassistant.components.camera import async_get_mjpeg_stream +from homeassistant.components.camera import ( + async_get_mjpeg_stream, + async_get_stream_source, +) +from homeassistant.components.generic.const import ( + CONF_CONTENT_TYPE, + CONF_FRAMERATE, + CONF_LIMIT_REFETCH_TO_URL_CHANGE, + CONF_STILL_IMAGE_URL, + CONF_STREAM_SOURCE, + DOMAIN, +) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.setup import async_setup_component -from tests.common import AsyncMock, Mock +from tests.common import AsyncMock, Mock, MockConfigEntry @respx.mock -async def test_fetching_url(hass, hass_client, fakeimgbytes_png, mock_av_open): +async def test_fetching_url(hass, hass_client, fakeimgbytes_png): """Test that it fetches the given url.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) - with mock_av_open: - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "username": "user", - "password": "pass", - "authentication": "basic", - } - }, - ) - await hass.async_block_till_done() + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + } + }, + ) + await hass.async_block_till_done() client = await hass_client() @@ -179,32 +190,35 @@ async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_j @respx.mock -async def test_stream_source( - hass, hass_client, hass_ws_client, fakeimgbytes_png, mock_av_open -): +async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png): """Test that the stream source is rendered.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png) hass.states.async_set("sensor.temp", "0") - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - }, - }, - ) - assert await async_setup_component(hass, "stream", {}) - await hass.async_block_till_done() + mock_entry = MockConfigEntry( + title="config_test", + domain=DOMAIN, + data={}, + options={ + CONF_STILL_IMAGE_URL: "http://example.com", + CONF_STREAM_SOURCE: 'http://example.com/{{ states.sensor.temp.state + "a" }}', + CONF_LIMIT_REFETCH_TO_URL_CHANGE: True, + CONF_FRAMERATE: 2, + CONF_CONTENT_TYPE: "image/png", + CONF_VERIFY_SSL: False, + CONF_USERNAME: "barney", + CONF_PASSWORD: "betty", + }, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + assert await async_setup_component(hass, "stream", {}) + await hass.async_block_till_done() hass.states.async_set("sensor.temp", "5") + stream_source = await async_get_stream_source(hass, "camera.config_test") + assert stream_source == "http://barney:betty@example.com/5a" with patch( "homeassistant.components.camera.Stream.endpoint_url", @@ -227,29 +241,26 @@ async def test_stream_source( @respx.mock -async def test_stream_source_error( - hass, hass_client, hass_ws_client, fakeimgbytes_png, mock_av_open -): +async def test_stream_source_error(hass, hass_client, hass_ws_client, fakeimgbytes_png): """Test that the stream source has an error.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - # Does not exist - "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - }, + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + # Does not exist + "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, }, - ) - assert await async_setup_component(hass, "stream", {}) - await hass.async_block_till_done() + }, + ) + assert await async_setup_component(hass, "stream", {}) + await hass.async_block_till_done() with patch( "homeassistant.components.camera.Stream.endpoint_url", @@ -275,30 +286,27 @@ async def test_stream_source_error( @respx.mock -async def test_setup_alternative_options( - hass, hass_ws_client, fakeimgbytes_png, mock_av_open -): +async def test_setup_alternative_options(hass, hass_ws_client, fakeimgbytes_png): """Test that the stream source is setup with different config options.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "authentication": "digest", - "username": "user", - "password": "pass", - "stream_source": "rtsp://example.com:554/rtsp/", - "rtsp_transport": "udp", - }, + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "authentication": "digest", + "username": "user", + "password": "pass", + "stream_source": "rtsp://example.com:554/rtsp/", + "rtsp_transport": "udp", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() assert hass.states.get("camera.config_test") @@ -346,7 +354,7 @@ async def test_no_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_ @respx.mock async def test_camera_content_type( - hass, hass_client, fakeimgbytes_svg, fakeimgbytes_jpg, mock_av_open + hass, hass_client, fakeimgbytes_svg, fakeimgbytes_jpg ): """Test generic camera with custom content_type.""" urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg" @@ -372,20 +380,18 @@ async def test_camera_content_type( "verify_ssl": True, } - with mock_av_open: - result1 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_jpg, - context={"source": SOURCE_IMPORT, "unique_id": 12345}, - ) - await hass.async_block_till_done() - with mock_av_open: - result2 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_svg, - context={"source": SOURCE_IMPORT, "unique_id": 54321}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_init( + "generic", + data=cam_config_jpg, + context={"source": SOURCE_IMPORT, "unique_id": 12345}, + ) + await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_init( + "generic", + data=cam_config_svg, + context={"source": SOURCE_IMPORT, "unique_id": 54321}, + ) + await hass.async_block_till_done() assert result1["type"] == "create_entry" assert result2["type"] == "create_entry" @@ -457,21 +463,20 @@ async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbyt assert await resp.read() == fakeimgbytes_png -async def test_no_still_image_url(hass, hass_client, mock_av_open): +async def test_no_still_image_url(hass, hass_client): """Test that the component can grab images from stream with no still_image_url.""" - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - }, + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() client = await hass_client() @@ -503,23 +508,22 @@ async def test_no_still_image_url(hass, hass_client, mock_av_open): assert await resp.read() == b"stream_keyframe_image" -async def test_frame_interval_property(hass, mock_av_open): +async def test_frame_interval_property(hass): """Test that the frame interval is calculated and returned correctly.""" - with mock_av_open: - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - "framerate": 5, - }, + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", + "framerate": 5, }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() request = Mock() with patch( diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index a525619d962..592d139f92e 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -2,15 +2,15 @@ import errno import os.path -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -import av import httpx import pytest import respx -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import config_entries, data_entry_flow from homeassistant.components.camera import async_get_image +from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( CONF_CONTENT_TYPE, CONF_FRAMERATE, @@ -23,6 +23,7 @@ from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, ) +from homeassistant.components.stream.worker import StreamWorkerError from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -57,10 +58,12 @@ TESTDATA_YAML = { @respx.mock -async def test_form(hass, fakeimg_png, mock_av_open, user_flow): +async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): """Test the form with a normal set of settings.""" - with mock_av_open as mock_setup: + with mock_create_stream as mock_setup, patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -81,12 +84,12 @@ async def test_form(hass, fakeimg_png, mock_av_open, user_flow): await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @respx.mock async def test_form_only_stillimage(hass, fakeimg_png, user_flow): """Test we complete ok if the user wants still images only.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -95,10 +98,12 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -112,7 +117,6 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() assert respx.calls.call_count == 1 @@ -121,10 +125,12 @@ async def test_form_only_stillimage_gif(hass, fakeimg_gif, user_flow): """Test we complete ok if the user wants a gif.""" data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @@ -136,10 +142,12 @@ async def test_form_only_svg_whitespace(hass, fakeimgbytes_svg, user_flow): respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_wspace_svg) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -161,62 +169,79 @@ async def test_form_only_still_sample(hass, user_flow, image_file): respx.get("http://127.0.0.1/testurl/1").respond(stream=image.read()) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @respx.mock @pytest.mark.parametrize( - ("template", "url", "expected_result"), + ("template", "url", "expected_result", "expected_errors"), [ # Test we can handle templates in strange parts of the url, #70961. ( "http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png", "http://localhost:8123/static/icons/favicon-apple-180x180.png", data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + None, ), ( "{% if 1 %}https://bla{% else %}https://yo{% endif %}", "https://bla/", data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + None, ), ( "http://{{example.org", "http://example.org", data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "template_error"}, ), ( "invalid1://invalid:4\\1", "invalid1://invalid:4%5c1", - data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "malformed_url"}, + ), + ( + "relative/urls/are/not/allowed.jpg", + "relative/urls/are/not/allowed.jpg", + data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "relative_url"}, ), ], ) async def test_still_template( - hass, user_flow, fakeimgbytes_png, template, url, expected_result + hass, user_flow, fakeimgbytes_png, template, url, expected_result, expected_errors ) -> None: """Test we can handle various templates.""" respx.get(url).respond(stream=fakeimgbytes_png) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) data[CONF_STILL_IMAGE_URL] = template - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == expected_result + assert result2.get("errors") == expected_errors @respx.mock -async def test_form_rtsp_mode(hass, fakeimg_png, mock_av_open, user_flow): +async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): """Test we complete ok if the user enters a stream url.""" - with mock_av_open as mock_setup: - data = TESTDATA.copy() - data[CONF_RTSP_TRANSPORT] = "tcp" - data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" + data = TESTDATA.copy() + data[CONF_RTSP_TRANSPORT] = "tcp" + data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" + with mock_create_stream as mock_setup, patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ): result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data ) @@ -240,20 +265,20 @@ async def test_form_rtsp_mode(hass, fakeimg_png, mock_av_open, user_flow): assert len(mock_setup.mock_calls) == 1 -async def test_form_only_stream(hass, mock_av_open, fakeimgbytes_jpg): +async def test_form_only_stream(hass, fakeimgbytes_jpg, mock_create_stream): """Test we complete ok if the user wants stream only.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" - with mock_av_open as mock_setup: + with mock_create_stream as mock_setup: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], data, ) + await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result3["title"] == "127_0_0_1" @@ -294,13 +319,13 @@ async def test_form_still_and_stream_not_provided(hass, user_flow): @respx.mock -async def test_form_image_timeout(hass, mock_av_open, user_flow): +async def test_form_image_timeout(hass, user_flow, mock_create_stream): """Test we handle invalid image timeout.""" respx.get("http://127.0.0.1/testurl/1").side_effect = [ httpx.TimeoutException, ] - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -312,10 +337,10 @@ async def test_form_image_timeout(hass, mock_av_open, user_flow): @respx.mock -async def test_form_stream_invalidimage(hass, mock_av_open, user_flow): +async def test_form_stream_invalidimage(hass, user_flow, mock_create_stream): """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid") - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -327,10 +352,10 @@ async def test_form_stream_invalidimage(hass, mock_av_open, user_flow): @respx.mock -async def test_form_stream_invalidimage2(hass, mock_av_open, user_flow): +async def test_form_stream_invalidimage2(hass, user_flow, mock_create_stream): """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=None) - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -342,10 +367,10 @@ async def test_form_stream_invalidimage2(hass, mock_av_open, user_flow): @respx.mock -async def test_form_stream_invalidimage3(hass, mock_av_open, user_flow): +async def test_form_stream_invalidimage3(hass, user_flow, mock_create_stream): """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF])) - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -356,43 +381,17 @@ async def test_form_stream_invalidimage3(hass, mock_av_open, user_flow): assert result2["errors"] == {"still_image_url": "invalid_still_image"} -@respx.mock -async def test_form_stream_file_not_found(hass, fakeimg_png, user_flow): - """Test we handle file not found.""" - with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.FileNotFoundError(0, 0), - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_file_not_found"} - - -@respx.mock -async def test_form_stream_http_not_found(hass, fakeimg_png, user_flow): - """Test we handle invalid auth.""" - with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.HTTPNotFoundError(0, 0), - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_http_not_found"} - - @respx.mock async def test_form_stream_timeout(hass, fakeimg_png, user_flow): """Test we handle invalid auth.""" with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.TimeoutError(0, 0), - ): + "homeassistant.components.generic.config_flow.create_stream" + ) as create_stream: + create_stream.return_value.start = AsyncMock() + create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() + create_stream.return_value.add_provider.return_value.part_recv.return_value = ( + False + ) result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -402,32 +401,18 @@ async def test_form_stream_timeout(hass, fakeimg_png, user_flow): @respx.mock -async def test_form_stream_unauthorised(hass, fakeimg_png, user_flow): - """Test we handle invalid auth.""" +async def test_form_stream_worker_error(hass, fakeimg_png, user_flow): + """Test we handle a StreamWorkerError and pass the message through.""" with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.HTTPUnauthorizedError(0, 0), + "homeassistant.components.generic.config_flow.create_stream", + side_effect=StreamWorkerError("Some message"), ): result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, ) assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_unauthorised"} - - -@respx.mock -async def test_form_stream_novideo(hass, fakeimg_png, user_flow): - """Test we handle invalid stream.""" - with patch( - "homeassistant.components.generic.config_flow.av.open", side_effect=KeyError() - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_no_video"} + assert result2["errors"] == {"stream_source": "Some message"} @respx.mock @@ -435,7 +420,7 @@ async def test_form_stream_permission_error(hass, fakeimgbytes_png, user_flow): """Test we handle permission error.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=PermissionError(), ): result2 = await hass.config_entries.flow.async_configure( @@ -450,7 +435,7 @@ async def test_form_stream_permission_error(hass, fakeimgbytes_png, user_flow): async def test_form_no_route_to_host(hass, fakeimg_png, user_flow): """Test we handle no route to host.""" with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), ): result2 = await hass.config_entries.flow.async_configure( @@ -465,7 +450,7 @@ async def test_form_no_route_to_host(hass, fakeimg_png, user_flow): async def test_form_stream_io_error(hass, fakeimg_png, user_flow): """Test we handle no io error when setting up stream.""" with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError(errno.EIO, "Input/output error"), ): result2 = await hass.config_entries.flow.async_configure( @@ -480,7 +465,7 @@ async def test_form_stream_io_error(hass, fakeimg_png, user_flow): async def test_form_oserror(hass, fakeimg_png, user_flow): """Test we handle OS error when setting up stream.""" with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError("Some other OSError"), ), pytest.raises(OSError): await hass.config_entries.flow.async_configure( @@ -490,11 +475,10 @@ async def test_form_oserror(hass, fakeimg_png, user_flow): @respx.mock -async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open): +async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream): """Test the options flow with a template error.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) - await setup.async_setup_component(hass, "persistent_notification", {}) mock_entry = MockConfigEntry( title="Test Camera", @@ -503,18 +487,18 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open): options=TESTDATA, ) - with mock_av_open: - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" - # try updating the still image url - data = TESTDATA.copy() - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" + # try updating the still image url + data = TESTDATA.copy() + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" + with mock_create_stream: result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=data, @@ -541,12 +525,48 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open): result4["flow_id"], user_input=data, ) + assert result5.get("type") == data_entry_flow.RESULT_TYPE_FORM assert result5["errors"] == {"stream_source": "template_error"} + # verify that an relative stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input=data, + ) + assert result6.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result6["errors"] == {"stream_source": "relative_url"} + + # verify that an malformed stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://example.com:45:56" + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], + user_input=data, + ) + assert result7.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result7["errors"] == {"stream_source": "malformed_url"} + + +async def test_slug(hass, caplog): + """ + Test that the slug function generates an error in case of invalid template. + + Other paths in the slug function are already tested by other tests. + """ + result = slug(hass, "http://127.0.0.2/testurl/{{1/0}}") + assert result is None + assert "Syntax error in" in caplog.text + + result = slug(hass, "http://example.com:999999999999/stream") + assert result is None + assert "Syntax error in" in caplog.text + @respx.mock -async def test_options_only_stream(hass, fakeimgbytes_png, mock_av_open): +async def test_options_only_stream(hass, fakeimgbytes_png, mock_create_stream): """Test the options flow without a still_image_url.""" respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) data = TESTDATA.copy() @@ -558,36 +578,35 @@ async def test_options_only_stream(hass, fakeimgbytes_png, mock_av_open): data={}, options=data, ) - with mock_av_open: - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" - # try updating the config options + # try updating the config options + with mock_create_stream: result3 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=data, ) - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" # These below can be deleted after deprecation period is finished. @respx.mock -async def test_import(hass, fakeimg_png, mock_av_open): +async def test_import(hass, fakeimg_png): """Test configuration.yaml import used during migration.""" - with mock_av_open: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) - # duplicate import should be aborted - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML + ) + # duplicate import should be aborted + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Yaml Defined Name" await hass.async_block_till_done() @@ -599,7 +618,7 @@ async def test_import(hass, fakeimg_png, mock_av_open): # These above can be deleted after deprecation period is finished. -async def test_unload_entry(hass, fakeimg_png, mock_av_open): +async def test_unload_entry(hass, fakeimg_png): """Test unloading the generic IP Camera entry.""" mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) mock_entry.add_to_hass(hass) @@ -669,7 +688,9 @@ async def test_migrate_existing_ids(hass) -> None: @respx.mock -async def test_use_wallclock_as_timestamps_option(hass, fakeimg_png, mock_av_open): +async def test_use_wallclock_as_timestamps_option( + hass, fakeimg_png, mock_create_stream +): """Test the use_wallclock_as_timestamps option flow.""" mock_entry = MockConfigEntry( @@ -679,19 +700,20 @@ async def test_use_wallclock_as_timestamps_option(hass, fakeimg_png, mock_av_ope options=TESTDATA, ) - with mock_av_open: - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init( - mock_entry.entry_id, context={"show_advanced_options": True} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init( + mock_entry.entry_id, context={"show_advanced_options": True} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + with patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ), mock_create_stream: result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 68176493445..4e251b4b006 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable import datetime +import http from typing import Any, Generator, TypeVar from unittest.mock import Mock, mock_open, patch @@ -27,6 +28,7 @@ YieldFixture = Generator[_T, None, None] CALENDAR_ID = "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com" +EMAIL_ADDRESS = "user@gmail.com" # Entities can either be created based on data directly from the API, or from # the yaml config that overrides the entity name and other settings. A test @@ -53,6 +55,9 @@ TEST_API_CALENDAR = { "defaultReminders": [], } +CLIENT_ID = "client-id" +CLIENT_SECRET = "client-secret" + @pytest.fixture def test_api_calendar(): @@ -99,7 +104,7 @@ def calendars_config(calendars_config_entity: dict[str, Any]) -> list[dict[str, ] -@pytest.fixture(autouse=True) +@pytest.fixture def mock_calendars_yaml( hass: HomeAssistant, calendars_config: list[dict[str, Any]], @@ -148,8 +153,8 @@ def creds( """Fixture that defines creds used in the test.""" return OAuth2Credentials( access_token="ACCESS_TOKEN", - client_id="client-id", - client_secret="client-secret", + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, refresh_token="REFRESH_TOKEN", token_expiry=token_expiry, token_uri="http://example.com", @@ -178,8 +183,15 @@ def config_entry_options() -> dict[str, Any] | None: return None +@pytest.fixture +def config_entry_unique_id() -> str: + """Fixture that returns the default config entry unique id.""" + return EMAIL_ADDRESS + + @pytest.fixture def config_entry( + config_entry_unique_id: str, token_scopes: list[str], config_entry_token_expiry: float, config_entry_options: dict[str, Any] | None, @@ -187,6 +199,7 @@ def config_entry( """Fixture to create a config entry for the integration.""" return MockConfigEntry( domain=DOMAIN, + unique_id=config_entry_unique_id, data={ "auth_implementation": "device_auth", "token": { @@ -271,12 +284,16 @@ def mock_calendar_get( """Fixture for returning a calendar get response.""" def _result( - calendar_id: str, response: dict[str, Any], exc: ClientError | None = None + calendar_id: str, + response: dict[str, Any], + exc: ClientError | None = None, + status: http.HTTPStatus = http.HTTPStatus.OK, ) -> None: aioclient_mock.get( f"{API_BASE_URL}/calendars/{calendar_id}", json=response, exc=exc, + status=status, ) return @@ -315,7 +332,7 @@ def google_config_track_new() -> None: @pytest.fixture def google_config(google_config_track_new: bool | None) -> dict[str, Any]: """Fixture for overriding component config.""" - google_config = {CONF_CLIENT_ID: "client-id", CONF_CLIENT_SECRET: "client-secret"} + google_config = {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET} if google_config_track_new is not None: google_config[CONF_TRACK_NEW] = google_config_track_new return google_config diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 794065ca09a..9a0cc2e47fa 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -13,17 +13,24 @@ from aiohttp.client_exceptions import ClientError from gcal_sync.auth import API_BASE_URL import pytest -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.google.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.template import DATE_STR_FORMAT import homeassistant.util.dt as dt_util -from .conftest import CALENDAR_ID, TEST_YAML_ENTITY, TEST_YAML_ENTITY_NAME +from .conftest import ( + CALENDAR_ID, + TEST_API_ENTITY, + TEST_API_ENTITY_NAME, + TEST_YAML_ENTITY, +) from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMockResponse -TEST_ENTITY = TEST_YAML_ENTITY -TEST_ENTITY_NAME = TEST_YAML_ENTITY_NAME +TEST_ENTITY = TEST_API_ENTITY +TEST_ENTITY_NAME = TEST_API_ENTITY_NAME TEST_EVENT = { "summary": "Test All Day Event", @@ -58,7 +65,6 @@ TEST_EVENT = { @pytest.fixture(autouse=True) def mock_test_setup( hass, - mock_calendars_yaml, test_api_calendar, mock_calendars_list, config_entry, @@ -87,12 +93,12 @@ def upcoming_date() -> dict[str, Any]: } -def upcoming_event_url() -> str: +def upcoming_event_url(entity: str = TEST_ENTITY) -> str: """Return a calendar API to return events created by upcoming().""" now = dt_util.now() start = (now - datetime.timedelta(minutes=60)).isoformat() end = (now + datetime.timedelta(minutes=60)).isoformat() - return f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" async def test_all_day_event( @@ -551,6 +557,7 @@ async def test_http_api_event_paging( async def test_opaque_event( hass, hass_client, + mock_calendars_yaml, mock_events_list_items, component_setup, transparency, @@ -566,7 +573,7 @@ async def test_opaque_event( assert await component_setup() client = await hass_client() - response = await client.get(upcoming_event_url()) + response = await client.get(upcoming_event_url(TEST_YAML_ENTITY)) assert response.status == HTTPStatus.OK events = await response.json() assert (len(events) > 0) == expect_visible_event @@ -660,3 +667,123 @@ async def test_future_event_offset_update_behavior( state = hass.states.get(TEST_ENTITY) assert state.state == STATE_OFF assert state.attributes["offset_reached"] + + +async def test_unique_id( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, +): + """Test entity is created with a unique id based on the config entry.""" + mock_events_list_items([]) + assert await component_setup() + + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{config_entry.unique_id}-{CALENDAR_ID}" + } + + +@pytest.mark.parametrize( + "old_unique_id", [CALENDAR_ID, f"{CALENDAR_ID}-we_are_we_are_a_test_calendar"] +) +async def test_unique_id_migration( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, + old_unique_id, +): + """Test that old unique id format is migrated to the new format that supports multiple accounts.""" + entity_registry = er.async_get(hass) + + # Create an entity using the old unique id format + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=old_unique_id, + config_entry=config_entry, + ) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == {old_unique_id} + + mock_events_list_items([]) + assert await component_setup() + + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{config_entry.unique_id}-{CALENDAR_ID}" + } + + +@pytest.mark.parametrize( + "calendars_config", + [ + [ + { + "cal_id": CALENDAR_ID, + "entities": [ + { + "device_id": "backyard_light", + "name": "Backyard Light", + "search": "#Backyard", + }, + { + "device_id": "front_light", + "name": "Front Light", + "search": "#Front", + }, + ], + } + ], + ], +) +async def test_invalid_unique_id_cleanup( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, + mock_calendars_yaml, +): + """Test that old unique id format that is not actually unique is removed.""" + entity_registry = er.async_get(hass) + + # Create an entity using the old unique id format + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=f"{CALENDAR_ID}-backyard_light", + config_entry=config_entry, + ) + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=f"{CALENDAR_ID}-front_light", + config_entry=config_entry, + ) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{CALENDAR_ID}-backyard_light", + f"{CALENDAR_ID}-front_light", + } + + mock_events_list_items([]) + assert await component_setup() + + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert not registry_entries diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index a346b02e6c2..24ad8a7b769 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -10,6 +10,7 @@ from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError from freezegun.api import FrozenDateTimeFactory from oauth2client.client import ( + DeviceFlowInfo, FlowExchangeError, OAuth2Credentials, OAuth2DeviceCodeError, @@ -27,13 +28,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.dt import utcnow -from .conftest import ComponentSetup, YieldFixture +from .conftest import ( + CLIENT_ID, + CLIENT_SECRET, + EMAIL_ADDRESS, + ComponentSetup, + YieldFixture, +) from tests.common import MockConfigEntry, async_fire_time_changed CODE_CHECK_INTERVAL = 1 CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) -EMAIL_ADDRESS = "user@gmail.com" @pytest.fixture(autouse=True) @@ -54,10 +60,17 @@ async def mock_code_flow( ) -> YieldFixture[Mock]: """Fixture for initiating OAuth flow.""" with patch( - "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", ) as mock_flow: - mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta - mock_flow.return_value.interval = CODE_CHECK_INTERVAL + mock_flow.return_value = DeviceFlowInfo.FromResponse( + { + "device_code": "4/4-GMMhmHCXhWEzkobqIHGG_EnNYYsAkukHspeYUk9E8", + "user_code": "GQVQ-JKEC", + "verification_url": "https://www.google.com/device", + "expires_in": code_expiration_delta.total_seconds(), + "interval": CODE_CHECK_INTERVAL, + } + ) yield mock_flow @@ -65,11 +78,18 @@ async def mock_code_flow( async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: """Fixture for mocking out the exchange for credentials.""" with patch( - "oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds + "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", + return_value=creds, ) as mock: yield mock +@pytest.fixture +async def primary_calendar_email() -> str: + """Fixture to override the google calendar primary email address.""" + return EMAIL_ADDRESS + + @pytest.fixture async def primary_calendar_error() -> ClientError | None: """Fixture for tests to inject an error during calendar lookup.""" @@ -78,12 +98,14 @@ async def primary_calendar_error() -> ClientError | None: @pytest.fixture(autouse=True) async def primary_calendar( - mock_calendar_get: Callable[[...], None], primary_calendar_error: ClientError | None + mock_calendar_get: Callable[[...], None], + primary_calendar_error: ClientError | None, + primary_calendar_email: str, ) -> None: """Fixture to return the primary calendar.""" mock_calendar_get( "primary", - {"id": EMAIL_ADDRESS, "summary": "Personal"}, + {"id": primary_calendar_email, "summary": "Personal"}, exc=primary_calendar_error, ) @@ -95,7 +117,6 @@ async def fire_alarm(hass, point_in_time): await hass.async_block_till_done() -@pytest.mark.freeze_time("2022-06-03 15:19:59-00:00") async def test_full_flow_yaml_creds( hass: HomeAssistant, mock_code_flow: Mock, @@ -118,9 +139,8 @@ async def test_full_flow_yaml_creds( "homeassistant.components.google.async_setup_entry", return_value=True ) as mock_setup: # Run one tick to invoke the credential exchange check - freezer.tick(CODE_CHECK_ALARM_TIMEDELTA) - await fire_alarm(hass, datetime.datetime.utcnow()) - await hass.async_block_till_done() + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"] ) @@ -130,11 +150,12 @@ async def test_full_flow_yaml_creds( assert "data" in result data = result["data"] assert "token" in data + assert 0 < data["token"]["expires_in"] <= 60 * 60 assert ( - data["token"]["expires_in"] - == 60 * 60 - CODE_CHECK_ALARM_TIMEDELTA.total_seconds() + datetime.datetime.now().timestamp() + <= data["token"]["expires_at"] + < (datetime.datetime.now() + datetime.timedelta(days=8)).timestamp() ) - assert data["token"]["expires_at"] == 1654273199.0 data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { @@ -165,7 +186,7 @@ async def test_full_flow_application_creds( assert await component_setup() await async_import_client_credential( - hass, DOMAIN, ClientCredential("client-id", "client-secret"), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" ) result = await hass.config_entries.flow.async_init( @@ -225,7 +246,7 @@ async def test_code_error( assert await component_setup() with patch( - "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", side_effect=OAuth2DeviceCodeError("Test Failure"), ): result = await hass.config_entries.flow.async_init( @@ -235,13 +256,13 @@ async def test_code_error( assert result.get("reason") == "oauth_error" -@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)]) +@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(seconds=50)]) async def test_expired_after_exchange( hass: HomeAssistant, mock_code_flow: Mock, component_setup: ComponentSetup, ) -> None: - """Test successful creds setup.""" + """Test credential exchange expires.""" assert await component_setup() result = await hass.config_entries.flow.async_init( @@ -252,10 +273,14 @@ async def test_expired_after_exchange( assert "description_placeholders" in result assert "url" in result["description_placeholders"] - # Run one tick to invoke the credential exchange check - now = utcnow() - await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) - await hass.async_block_till_done() + # Fail first attempt then advance clock past exchange timeout + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", + side_effect=FlowExchangeError(), + ): + now = utcnow() + await fire_alarm(hass, now + datetime.timedelta(seconds=65)) + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) assert result.get("type") == "abort" @@ -282,7 +307,7 @@ async def test_exchange_error( # Run one tick to invoke the credential exchange check now = utcnow() with patch( - "oauth2client.client.OAuth2WebServerFlow.step2_exchange", + "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", side_effect=FlowExchangeError(), ): now += CODE_CHECK_ALARM_TIMEDELTA @@ -327,26 +352,107 @@ async def test_exchange_error( assert len(entries) == 1 -async def test_existing_config_entry( +@pytest.mark.parametrize("google_config", [None]) +async def test_duplicate_config_entries( hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + config: dict[str, Any], config_entry: MockConfigEntry, component_setup: ComponentSetup, ) -> None: - """Test can't configure when config entry already exists.""" + """Test that the same account cannot be setup twice.""" + assert await component_setup() + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + ) + + # Load a config entry config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - assert await component_setup() - + # Start a new config flow using the same credential result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) assert result.get("type") == "abort" assert result.get("reason") == "already_configured" +@pytest.mark.parametrize( + "google_config,primary_calendar_email", [(None, "another-email@example.com")] +) +async def test_multiple_config_entries( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + config: dict[str, Any], + config_entry: MockConfigEntry, + component_setup: ComponentSetup, +) -> None: + """Test that multiple config entries can be set at once.""" + assert await component_setup() + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + ) + + # Load a config entry + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Start a new config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"] + ) + assert result.get("type") == "create_entry" + assert result.get("title") == "another-email@example.com" + assert len(mock_setup.mock_calls) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + async def test_missing_configuration( hass: HomeAssistant, ) -> None: @@ -385,8 +491,8 @@ async def test_wrong_configuration( config_entry_oauth2_flow.LocalOAuth2Implementation( hass, DOMAIN, - "client-id", - "client-secret", + CLIENT_ID, + CLIENT_SECRET, "http://example/authorize", "http://example/token", ), @@ -499,7 +605,7 @@ async def test_reauth_flow( @pytest.mark.parametrize("primary_calendar_error", [ClientError()]) -async def test_title_lookup_failure( +async def test_calendar_lookup_failure( hass: HomeAssistant, mock_code_flow: Mock, mock_exchange: Mock, @@ -516,9 +622,7 @@ async def test_title_lookup_failure( assert "description_placeholders" in result assert "url" in result["description_placeholders"] - with patch( - "homeassistant.components.google.async_setup_entry", return_value=True - ) as mock_setup: + with patch("homeassistant.components.google.async_setup_entry", return_value=True): # Run one tick to invoke the credential exchange check now = utcnow() await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) @@ -527,12 +631,8 @@ async def test_title_lookup_failure( flow_id=result["flow_id"] ) - assert result.get("type") == "create_entry" - assert result.get("title") == "Import from configuration.yaml" - - assert len(mock_setup.mock_calls) == 1 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 + assert result.get("type") == "abort" + assert result.get("reason") == "cannot_connect" async def test_options_flow_triggers_reauth( diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index f2cf067f7bb..d9b9ec8ed03 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -9,16 +9,14 @@ from typing import Any from unittest.mock import Mock, patch import pytest +import voluptuous as vol from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.google import ( - DOMAIN, - SERVICE_ADD_EVENT, - SERVICE_SCAN_CALENDARS, -) +from homeassistant.components.google import DOMAIN, SERVICE_ADD_EVENT +from homeassistant.components.google.calendar import SERVICE_CREATE_EVENT from homeassistant.components.google.const import CONF_CALENDAR_ACCESS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF @@ -28,6 +26,7 @@ from homeassistant.util.dt import utcnow from .conftest import ( CALENDAR_ID, + EMAIL_ADDRESS, TEST_API_ENTITY, TEST_API_ENTITY_NAME, TEST_YAML_ENTITY, @@ -44,6 +43,9 @@ EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() # Typing helpers HassApi = Callable[[], Awaitable[dict[str, Any]]] +TEST_EVENT_SUMMARY = "Test Summary" +TEST_EVENT_DESCRIPTION = "Test Description" + def assert_state(actual: State | None, expected: State | None) -> None: """Assert that the two states are equal.""" @@ -61,6 +63,46 @@ def setup_config_entry( ) -> MockConfigEntry: """Fixture to initialize the config entry.""" config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture( + params=[ + ( + SERVICE_ADD_EVENT, + {"calendar_id": CALENDAR_ID}, + None, + ), + ( + SERVICE_CREATE_EVENT, + {}, + {"entity_id": TEST_API_ENTITY}, + ), + ], + ids=("add_event", "create_event"), +) +def add_event_call_service( + hass: HomeAssistant, + request: Any, +) -> Callable[dict[str, Any], Awaitable[None]]: + """Fixture for calling the add or create event service.""" + (service_call, data, target) = request.param + + async def call_service(params: dict[str, Any]) -> None: + await hass.services.async_call( + DOMAIN, + service_call, + { + **data, + **params, + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, + }, + target=target, + blocking=True, + ) + + return call_service async def test_unload_entry( @@ -140,17 +182,24 @@ async def test_invalid_calendar_yaml( component_setup: ComponentSetup, calendars_config: list[dict[str, Any]], mock_calendars_yaml: None, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, ) -> None: - """Test setup with missing entity id fields fails to setup the config entry.""" + """Test setup with missing entity id fields fails to load the platform.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.LOADED assert not hass.states.get(TEST_YAML_ENTITY) + assert not hass.states.get(TEST_API_ENTITY) async def test_calendar_yaml_error( @@ -172,11 +221,9 @@ async def test_calendar_yaml_error( assert hass.states.get(TEST_API_ENTITY) -@pytest.mark.parametrize("calendars_config", [[]]) -async def test_found_calendar_from_api( +async def test_init_calendar( hass: HomeAssistant, component_setup: ComponentSetup, - mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, @@ -198,13 +245,12 @@ async def test_found_calendar_from_api( @pytest.mark.parametrize( - "calendars_config,google_config,config_entry_options", - [([], {}, {CONF_CALENDAR_ACCESS: "read_write"})], + "google_config,config_entry_options", + [({}, {CONF_CALENDAR_ACCESS: "read_write"})], ) async def test_load_application_credentials( hass: HomeAssistant, component_setup: ComponentSetup, - mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, @@ -229,6 +275,59 @@ async def test_load_application_credentials( assert not hass.states.get(TEST_YAML_ENTITY) +async def test_multiple_config_entries( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test finding a calendar from the API.""" + + assert await component_setup() + + config_entry1 = MockConfigEntry( + domain=DOMAIN, data=config_entry.data, unique_id=EMAIL_ADDRESS + ) + calendar1 = { + **test_api_calendar, + "id": "calendar-id1", + "summary": "Example Calendar 1", + } + + mock_calendars_list({"items": [calendar1]}) + mock_events_list({}, calendar_id="calendar-id1") + config_entry1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry1.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.example_calendar_1") + assert state + assert state.name == "Example Calendar 1" + assert state.state == STATE_OFF + + config_entry2 = MockConfigEntry( + domain=DOMAIN, data=config_entry.data, unique_id="other-address@example.com" + ) + calendar2 = { + **test_api_calendar, + "id": "calendar-id2", + "summary": "Example Calendar 2", + } + aioclient_mock.clear_requests() + mock_calendars_list({"items": [calendar2]}) + mock_events_list({}, calendar_id="calendar-id2") + config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry2.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.example_calendar_2") + assert state + assert state.name == "Example Calendar 2" + + @pytest.mark.parametrize( "calendars_config_track,expected_state,google_config_track_new", [ @@ -294,28 +393,144 @@ async def test_calendar_config_track_new( assert_state(state, expected_state) -async def test_add_event_missing_required_fields( +@pytest.mark.parametrize( + "date_fields,expected_error,error_match", + [ + ( + {}, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in", + ), + ( + { + "start_date": "2022-04-01", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T06:00:00", + }, + vol.error.MultipleInvalid, + "Start and end datetimes must both be specified", + ), + ( + { + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in.", + ), + ( + { + "start_date": "2022-04-01", + "start_date_time": "2022-04-01T06:00:00", + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T06:00:00", + "end_date_time": "2022-04-01T07:00:00", + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "start_date": "2022-04-01", + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "start_date_time": "2022-04-01T07:00:00", + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "in": { + "days": 2, + "weeks": 2, + } + }, + vol.error.MultipleInvalid, + "two or more values in the same group of exclusion 'event_types'", + ), + ( + { + "start_date": "2022-04-01", + "end_date": "2022-04-02", + "in": { + "days": 2, + }, + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T07:00:00", + "end_date_time": "2022-04-01T07:00:00", + "in": { + "days": 2, + }, + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ], + ids=[ + "missing_all", + "missing_end_date", + "missing_start_date", + "missing_end_datetime", + "missing_start_datetime", + "multiple_start", + "multiple_end", + "missing_end_date", + "missing_end_date_time", + "multiple_in", + "unexpected_in_with_date", + "unexpected_in_with_datetime", + ], +) +async def test_add_event_invalid_params( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], + date_fields: dict[str, Any], + expected_error: type[Exception], + error_match: str | None, ) -> None: - """Test service call that adds an event missing required fields.""" + """Test service calls with incorrect fields.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() - with pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, - { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", - }, - blocking=True, - ) + with pytest.raises(expected_error, match=error_match): + await add_event_call_service(date_fields) @pytest.mark.parametrize( @@ -340,40 +555,34 @@ async def test_add_event_date_in_x( mock_calendars_list: ApiResult, mock_insert_event: Callable[[..., dict[str, Any]], None], test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, date_fields: dict[str, Any], start_timedelta: datetime.timedelta, end_timedelta: datetime.timedelta, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with various time ranges.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() now = datetime.datetime.now() start_date = now + start_timedelta end_date = now + end_timedelta + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, - { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", - **date_fields, - }, - blocking=True, - ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + await add_event_call_service(date_fields) + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": {"date": start_date.date().isoformat()}, "end": {"date": end_date.date().isoformat()}, } @@ -383,39 +592,38 @@ async def test_add_event_date( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], mock_insert_event: Callable[[str, dict[str, Any]], None], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that sets a date range.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() now = utcnow() today = now.date() end_date = today + datetime.timedelta(days=2) + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, + await add_event_call_service( { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", "start_date": today.isoformat(), "end_date": end_date.isoformat(), }, - blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": {"date": today.isoformat()}, "end": {"date": end_date.isoformat()}, } @@ -427,38 +635,36 @@ async def test_add_event_date_time( mock_calendars_list: ApiResult, mock_insert_event: Callable[[str, dict[str, Any]], None], test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with a date time range.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() start_datetime = datetime.datetime.now() delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, + await add_event_call_service( { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", "start_date_time": start_datetime.isoformat(), "end_date_time": end_datetime.isoformat(), }, - blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina", @@ -470,57 +676,6 @@ async def test_add_event_date_time( } -async def test_scan_calendars( - hass: HomeAssistant, - component_setup: ComponentSetup, - mock_calendars_list: ApiResult, - mock_events_list: ApiResult, - setup_config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test finding a calendar from the API.""" - - mock_calendars_list({"items": []}) - assert await component_setup() - - calendar_1 = { - "id": "calendar-id-1", - "summary": "Calendar 1", - } - calendar_2 = { - "id": "calendar-id-2", - "summary": "Calendar 2", - } - - aioclient_mock.clear_requests() - mock_calendars_list({"items": [calendar_1]}) - mock_events_list({}, calendar_id="calendar-id-1") - await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) - await hass.async_block_till_done() - - state = hass.states.get("calendar.calendar_1") - assert state - assert state.name == "Calendar 1" - assert state.state == STATE_OFF - assert not hass.states.get("calendar.calendar_2") - - aioclient_mock.clear_requests() - mock_calendars_list({"items": [calendar_1, calendar_2]}) - mock_events_list({}, calendar_id="calendar-id-1") - mock_events_list({}, calendar_id="calendar-id-2") - await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) - await hass.async_block_till_done() - - state = hass.states.get("calendar.calendar_1") - assert state - assert state.name == "Calendar 1" - assert state.state == STATE_OFF - state = hass.states.get("calendar.calendar_2") - assert state - assert state.name == "Calendar 2" - assert state.state == STATE_OFF - - @pytest.mark.parametrize( "config_entry_token_expiry", [datetime.datetime.max.timestamp() + 1] ) @@ -693,3 +848,72 @@ async def test_update_will_reload( ) await hass.async_block_till_done() mock_reload.assert_called_once() + + +@pytest.mark.parametrize("config_entry_unique_id", [None]) +async def test_assign_unique_id( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + mock_calendar_get: Callable[[...], None], + setup_config_entry: MockConfigEntry, +) -> None: + """Test an existing config is updated to have unique id if it does not exist.""" + + assert setup_config_entry.state is ConfigEntryState.NOT_LOADED + assert setup_config_entry.unique_id is None + + mock_calendar_get( + "primary", + {"id": EMAIL_ADDRESS, "summary": "Personal"}, + ) + + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + assert setup_config_entry.state is ConfigEntryState.LOADED + assert setup_config_entry.unique_id == EMAIL_ADDRESS + + +@pytest.mark.parametrize( + "config_entry_unique_id,request_status,config_entry_status", + [ + (None, http.HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_RETRY), + ( + None, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ], +) +async def test_assign_unique_id_failure( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + mock_calendar_get: Callable[[...], None], + setup_config_entry: MockConfigEntry, + request_status: http.HTTPStatus, + config_entry_status: ConfigEntryState, +) -> None: + """Test lookup failures during unique id assignment are handled gracefully.""" + + assert setup_config_entry.state is ConfigEntryState.NOT_LOADED + assert setup_config_entry.unique_id is None + + mock_calendar_get( + "primary", + {}, + status=request_status, + ) + + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + assert setup_config_entry.state is config_entry_status + assert setup_config_entry.unique_id is None diff --git a/tests/components/google_assistant/test_button.py b/tests/components/google_assistant/test_button.py new file mode 100644 index 00000000000..0783b70dff3 --- /dev/null +++ b/tests/components/google_assistant/test_button.py @@ -0,0 +1,55 @@ +"""Test buttons.""" + +from unittest.mock import patch + +from pytest import raises + +from homeassistant.components import google_assistant as ga +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .test_http import DUMMY_CONFIG + +from tests.common import MockUser + + +async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser): + """Test sync button.""" + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + await hass.async_block_till_done() + + state = hass.states.get("button.synchronize_devices") + assert state + + config_entry = hass.config_entries.async_entries("google_assistant")[0] + google_config: ga.GoogleConfig = hass.data[ga.DOMAIN][config_entry.entry_id] + + with patch.object(google_config, "async_sync_entities") as mock_sync_entities: + mock_sync_entities.return_value = 200 + context = Context(user_id=hass_owner_user.id) + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.synchronize_devices"}, + blocking=True, + context=context, + ) + mock_sync_entities.assert_called_once_with(hass_owner_user.id) + + with raises(HomeAssistantError): + mock_sync_entities.return_value = 400 + + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.synchronize_devices"}, + blocking=True, + context=context, + ) diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py new file mode 100644 index 00000000000..13721c17f88 --- /dev/null +++ b/tests/components/google_assistant/test_diagnostics.py @@ -0,0 +1,109 @@ +"""Test diagnostics.""" + +from typing import Any +from unittest.mock import ANY + +from homeassistant import core, setup +from homeassistant.components import google_assistant as ga, switch +from homeassistant.setup import async_setup_component + +from .test_http import DUMMY_CONFIG + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics(hass: core.HomeAssistant, hass_client: Any): + """Test diagnostics v1.""" + + await setup.async_setup_component( + hass, switch.DOMAIN, {"switch": [{"platform": "demo"}]} + ) + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + config_entry = hass.config_entries.async_entries("google_assistant")[0] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == { + "config_entry": { + "data": {"project_id": "1234"}, + "disabled_by": None, + "domain": "google_assistant", + "entry_id": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "import", + "title": "1234", + "unique_id": "1234", + "version": 1, + }, + "sync": { + "agentUserId": "**REDACTED**", + "devices": [ + { + "attributes": {"commandOnlyOnOff": True}, + "id": "switch.decorative_lights", + "otherDeviceIds": [{"deviceId": "switch.decorative_lights"}], + "name": {"name": "Decorative Lights"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.SWITCH", + "willReportState": False, + "customData": { + "baseUrl": "**REDACTED**", + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": "**REDACTED**", + "uuid": "**REDACTED**", + "webhookId": None, + }, + }, + { + "attributes": {}, + "id": "switch.ac", + "otherDeviceIds": [{"deviceId": "switch.ac"}], + "name": {"name": "AC"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.OUTLET", + "willReportState": False, + "customData": { + "baseUrl": "**REDACTED**", + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": "**REDACTED**", + "uuid": "**REDACTED**", + "webhookId": None, + }, + }, + ], + }, + "yaml_config": { + "expose_by_default": True, + "exposed_domains": [ + "alarm_control_panel", + "binary_sensor", + "climate", + "cover", + "fan", + "group", + "humidifier", + "input_boolean", + "input_select", + "light", + "lock", + "media_player", + "scene", + "script", + "select", + "sensor", + "switch", + "vacuum", + ], + "project_id": "1234", + "report_state": False, + "service_account": "**REDACTED**", + }, + } diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 1ab573baf2a..8898fc7ef76 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -327,7 +327,9 @@ async def test_sync_entities_all(agents, result): def test_supported_features_string(caplog): """Test bad supported features.""" entity = helpers.GoogleEntity( - None, None, State("test.entity_id", "on", {"supported_features": "invalid"}) + None, + MockConfig(), + State("test.entity_id", "on", {"supported_features": "invalid"}), ) assert entity.is_supported() is False assert "Entity test.entity_id contains invalid supported_features value invalid" @@ -427,3 +429,34 @@ async def test_config_local_sdk_warn_version(hass, hass_client, caplog, version) f"Local SDK version is too old ({version}), check documentation on how " "to update to the latest version" ) in caplog.text + + +def test_is_supported_cached(): + """Test is_supported is cached.""" + config = MockConfig() + + def entity(features: int): + return helpers.GoogleEntity( + None, + config, + State("test.entity_id", "on", {"supported_features": features}), + ) + + 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 + + # Supported feature changes, so we calculate again + assert entity(2).is_supported() is True + assert len(mock_traits.mock_calls) == 2 + + mock_traits.reset_mock() + + # Supported feature is same, so we do not calculate again + mock_traits.side_effect = ValueError + + assert entity(2).is_supported() is True + assert len(mock_traits.mock_calls) == 0 diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py index 69198b99aaa..bdd6932c91d 100644 --- a/tests/components/google_assistant/test_init.py +++ b/tests/components/google_assistant/test_init.py @@ -2,11 +2,47 @@ from http import HTTPStatus from homeassistant.components import google_assistant as ga -from homeassistant.core import Context +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from .test_http import DUMMY_CONFIG +from tests.common import MockConfigEntry + + +async def test_import(hass: HomeAssistant): + """Test import.""" + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + entries = hass.config_entries.async_entries("google_assistant") + assert len(entries) == 1 + assert entries[0].data[ga.const.CONF_PROJECT_ID] == "1234" + + +async def test_import_changed(hass: HomeAssistant): + """Test import with changed project id.""" + + old_entry = MockConfigEntry( + domain=ga.DOMAIN, data={ga.const.CONF_PROJECT_ID: "4321"}, source="import" + ) + old_entry.add_to_hass(hass) + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries("google_assistant") + assert len(entries) == 1 + assert entries[0].data[ga.const.CONF_PROJECT_ID] == "1234" + async def test_request_sync_service(aioclient_mock, hass): """Test that it posts to the request_sync url.""" diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 4b11910999a..684a6db2640 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1026,7 +1026,7 @@ async def test_device_class_switch(hass, device_class, google_type): ("garage_door", "action.devices.types.GARAGE"), ("lock", "action.devices.types.SENSOR"), ("opening", "action.devices.types.SENSOR"), - ("window", "action.devices.types.SENSOR"), + ("window", "action.devices.types.WINDOW"), ], ) async def test_device_class_binary_sensor(hass, device_class, google_type): diff --git a/tests/components/google_pubsub/test_init.py b/tests/components/google_pubsub/test_init.py index 1b6d1dbf4b4..71fab923972 100644 --- a/tests/components/google_pubsub/test_init.py +++ b/tests/components/google_pubsub/test_init.py @@ -222,7 +222,7 @@ async def test_filtered_allowlist(hass, mock_client): FilterTest("light.excluded_test", False), FilterTest("light.excluded", False), FilterTest("sensor.included_test", True), - FilterTest("climate.included_test", False), + FilterTest("climate.included_test", True), ] for test in tests: diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py index a0872b11f16..fbc19904faa 100644 --- a/tests/components/group/test_binary_sensor.py +++ b/tests/components/group/test_binary_sensor.py @@ -50,7 +50,13 @@ async def test_default_state(hass): async def test_state_reporting_all(hass): - """Test the state reporting.""" + """Test the state reporting in 'all' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is off if at least one group member is off. + Otherwise, the group state is on. + """ await async_setup_component( hass, BINARY_SENSOR_DOMAIN, @@ -68,26 +74,12 @@ async def test_state_reporting_all(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN - - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF - - hass.states.async_set("binary_sensor.test1", STATE_OFF) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF - - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_ON) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + # Initial state with no group member in the state machine -> unavailable + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + # All group members unavailable -> unavailable hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() @@ -95,6 +87,12 @@ async def test_state_reporting_all(hass): hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE ) + # At least one member unknown or unavailable -> group unknown + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) await hass.async_block_till_done() @@ -105,9 +103,55 @@ async def test_state_reporting_all(hass): await hass.async_block_till_done() assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + # At least one member off -> group off + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + # Otherwise -> on + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("binary_sensor.test1") + hass.states.async_remove("binary_sensor.test2") + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + async def test_state_reporting_any(hass): - """Test the state reporting.""" + """Test the state reporting in 'any' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is on if at least one group member is on. + Otherwise, the group state is off. + """ await async_setup_component( hass, BINARY_SENSOR_DOMAIN, @@ -126,26 +170,17 @@ async def test_state_reporting_any(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.binary_sensor_group") + assert entry + assert entry.unique_id == "unique_identifier" - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON - - hass.states.async_set("binary_sensor.test1", STATE_OFF) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF - - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_ON) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + # Initial state with no group member in the state machine -> unavailable + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + # All group members unavailable -> unavailable hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() @@ -153,17 +188,59 @@ async def test_state_reporting_any(hass): hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE ) - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("binary_sensor.binary_sensor_group") - assert entry - assert entry.unique_id == "unique_identifier" + # All group members unknown -> unknown + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + # Group members unknown or unavailable -> unknown + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + # At least one member on -> group on + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) await hass.async_block_till_done() assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON - hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) - hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) + # Otherwise -> off + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("binary_sensor.test1") + hass.states.async_remove("binary_sensor.test2") + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 83741a2e851..9d6b099557d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -348,7 +348,10 @@ async def test_all_options( @pytest.mark.parametrize( "hide_members,hidden_by_initial,hidden_by", - ((False, "integration", None), (True, None, "integration")), + ( + (False, er.RegistryEntryHider.INTEGRATION, None), + (True, None, er.RegistryEntryHider.INTEGRATION), + ), ) @pytest.mark.parametrize( "group_type,extra_input", diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index d090141a9d2..57c54c7c502 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -33,6 +33,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er @@ -99,116 +100,160 @@ async def setup_comp(hass, config_count): @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) async def test_state(hass, setup_comp): - """Test handling of state.""" + """Test handling of state. + + The group state is unknown if all group members are unknown or unavailable. + Otherwise, the group state is opening if at least one group member is opening. + Otherwise, the group state is closing if at least one group member is closing. + Otherwise, the group state is open if at least one group member is open. + Otherwise, the group state is closed. + """ state = hass.states.get(COVER_GROUP) - # No entity has a valid state -> group state unknown - assert state.state == STATE_UNKNOWN + # No entity has a valid state -> group state unavailable + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert ATTR_ENTITY_ID not in state.attributes + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + # Test group members exposed as attribute + hass.states.async_set(DEMO_COVER, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) assert state.attributes[ATTR_ENTITY_ID] == [ DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT, ] - assert ATTR_ASSUMED_STATE not in state.attributes - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 - assert ATTR_CURRENT_POSITION not in state.attributes - assert ATTR_CURRENT_TILT_POSITION not in state.attributes - # Set all entities as closed -> group state closed - hass.states.async_set(DEMO_COVER, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) + # The group state is unavailable if all group members are unavailable. + hass.states.async_set(DEMO_COVER, STATE_UNAVAILABLE, {}) + hass.states.async_set(DEMO_COVER_POS, STATE_UNAVAILABLE, {}) + hass.states.async_set(DEMO_COVER_TILT, STATE_UNAVAILABLE, {}) + hass.states.async_set(DEMO_TILT, STATE_UNAVAILABLE, {}) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.state == STATE_CLOSED + assert state.state == STATE_UNAVAILABLE - # Set all entities as open -> group state open - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_OPEN, {}) - hass.states.async_set(DEMO_TILT, STATE_OPEN, {}) + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_UNKNOWN + + # At least one member opening -> group opening + for state_1 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_2 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_3 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_OPENING, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPENING + + # At least one member closing -> group closing + for state_1 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_2 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_3 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_CLOSING, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSING + + # At least one member open -> group open + for state_1 in (STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_OPEN, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + + # At least one member closed -> group closed + for state_1 in (STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSED + + # All group members removed from the state machine -> unavailable + hass.states.async_remove(DEMO_COVER) + hass.states.async_remove(DEMO_COVER_POS) + hass.states.async_remove(DEMO_COVER_TILT) + hass.states.async_remove(DEMO_TILT) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN - - # Set first entity as open -> group state open - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN - - # Set last entity as open -> group state open - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN - - # Set conflicting valid states -> opening state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_OPENING, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSING, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPENING - - # Set all entities to unknown state -> group state unknown - hass.states.async_set(DEMO_COVER, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_TILT, STATE_UNKNOWN, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_UNKNOWN - - # Set one entity to unknown state -> open state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_OPEN, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN - - # Set one entity to unknown state -> opening state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_OPENING, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPENING - - # Set one entity to unknown state -> closing state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSING, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_CLOSING + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) async def test_attributes(hass, setup_comp): """Test handling of state attributes.""" state = hass.states.get(COVER_GROUP) - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME - assert state.attributes[ATTR_ENTITY_ID] == [ - DEMO_COVER, - DEMO_COVER_POS, - DEMO_COVER_TILT, - DEMO_TILT, - ] + assert ATTR_ENTITY_ID not in state.attributes assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert ATTR_CURRENT_POSITION not in state.attributes @@ -220,6 +265,12 @@ async def test_attributes(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED + assert state.attributes[ATTR_ENTITY_ID] == [ + DEMO_COVER, + DEMO_COVER_POS, + DEMO_COVER_TILT, + DEMO_TILT, + ] # Set entity as opening hass.states.async_set(DEMO_COVER, STATE_OPENING, {}) diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 19b4fe4670a..bb2cf311191 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -33,6 +33,8 @@ from homeassistant.const import ( CONF_UNIQUE_ID, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import CoreState from homeassistant.helpers import entity_registry as er @@ -111,59 +113,91 @@ async def setup_comp(hass, config_count): @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) async def test_state(hass, setup_comp): - """Test handling of state.""" + """Test handling of state. + + The group state is on if at least one group member is on. + Otherwise, the group state is off. + """ state = hass.states.get(FAN_GROUP) - # No entity has a valid state -> group state off - assert state.state == STATE_OFF + # No entity has a valid state -> group state unavailable + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert ATTR_ENTITY_ID not in state.attributes + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Test group members exposed as attribute + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) assert state.attributes[ATTR_ENTITY_ID] == [ *FULL_FAN_ENTITY_IDS, *LIMITED_FAN_ENTITY_IDS, ] - assert ATTR_ASSUMED_STATE not in state.attributes - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 - # Set all entities as on -> group state on - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) + # All group members unavailable -> unavailable + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNAVAILABLE) await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) - assert state.state == STATE_ON + assert state.state == STATE_UNAVAILABLE - # Set all entities as off -> group state off - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + print("meh") + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set( + PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNKNOWN, {} + ) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_UNKNOWN - # Set first entity as on -> group state on - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_ON + # The group state is off if all group members are off, unknown or unavailable. + for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_OFF - # Set last entity as on -> group state on - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_ON + # At least one member on -> group on + for state_1 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON # now remove an entity hass.states.async_remove(PERCENTAGE_LIMITED_FAN_ENTITY_ID) await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # now remove all entities + hass.states.async_remove(CEILING_FAN_ENTITY_ID) + hass.states.async_remove(LIVING_ROOM_FAN_ENTITY_ID) + hass.states.async_remove(PERCENTAGE_FULL_FAN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_UNAVAILABLE assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -178,12 +212,9 @@ async def test_state(hass, setup_comp): async def test_attributes(hass, setup_comp): """Test handling of state attributes.""" state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME - assert state.attributes[ATTR_ENTITY_ID] == [ - *FULL_FAN_ENTITY_IDS, - *LIMITED_FAN_ENTITY_IDS, - ] + assert ATTR_ENTITY_ID not in state.attributes assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) @@ -193,6 +224,10 @@ async def test_attributes(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON + assert state.attributes[ATTR_ENTITY_ID] == [ + *FULL_FAN_ENTITY_IDS, + *LIMITED_FAN_ENTITY_IDS, + ] # Add Entity that supports speed hass.states.async_set( diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 56553ff263c..945f6555789 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1421,12 +1421,12 @@ async def test_setup_and_remove_config_entry( @pytest.mark.parametrize( "hide_members,hidden_by_initial,hidden_by", ( - (False, "integration", "integration"), + (False, er.RegistryEntryHider.INTEGRATION, er.RegistryEntryHider.INTEGRATION), (False, None, None), - (False, "user", "user"), - (True, "integration", None), + (False, er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), + (True, er.RegistryEntryHider.INTEGRATION, None), (True, None, None), - (True, "user", "user"), + (True, er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), ), ) @pytest.mark.parametrize( @@ -1444,7 +1444,7 @@ async def test_unhide_members_on_remove( group_type: str, extra_options: dict[str, Any], hide_members: bool, - hidden_by_initial: str, + hidden_by_initial: er.RegistryEntryHider, hidden_by: str, ) -> None: """Test removing a config entry.""" diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index d5f7abedb44..f3083812553 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -88,8 +88,14 @@ async def test_default_state(hass): assert entry.unique_id == "unique_identifier" -async def test_state_reporting(hass): - """Test the state reporting.""" +async def test_state_reporting_any(hass): + """Test the state reporting in 'any' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is on if at least one group member is on. + Otherwise, the group state is off. + """ await async_setup_component( hass, LIGHT_DOMAIN, @@ -105,29 +111,79 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("light.test1", STATE_ON) - hass.states.async_set("light.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("light.light_group").state == STATE_ON - - hass.states.async_set("light.test1", STATE_ON) - hass.states.async_set("light.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("light.light_group").state == STATE_ON - - hass.states.async_set("light.test1", STATE_OFF) - hass.states.async_set("light.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("light.light_group").state == STATE_OFF + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + # All group members unavailable -> unavailable hass.states.async_set("light.test1", STATE_UNAVAILABLE) hass.states.async_set("light.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + # All group members unknown -> unknown + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + # Group members unknown or unavailable -> unknown + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + # At least one member on -> group on + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + # Otherwise -> off + hass.states.async_set("light.test1", STATE_OFF) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + hass.states.async_set("light.test1", STATE_UNAVAILABLE) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("light.test1") + hass.states.async_remove("light.test2") + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + async def test_state_reporting_all(hass): - """Test the state reporting.""" + """Test the state reporting in 'all' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is off if at least one group member is off. + Otherwise, the group state is on. + """ await async_setup_component( hass, LIGHT_DOMAIN, @@ -143,11 +199,47 @@ async def test_state_reporting_all(hass): await hass.async_start() await hass.async_block_till_done() + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + + # All group members unavailable -> unavailable + hass.states.async_set("light.test1", STATE_UNAVAILABLE) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + + # At least one member unknown or unavailable -> group unknown hass.states.async_set("light.test1", STATE_ON) hass.states.async_set("light.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_UNKNOWN + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("light.test1", STATE_OFF) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("light.test1", STATE_OFF) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + # At least one member off -> group off hass.states.async_set("light.test1", STATE_ON) hass.states.async_set("light.test2", STATE_OFF) await hass.async_block_till_done() @@ -158,13 +250,15 @@ async def test_state_reporting_all(hass): await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_OFF + # Otherwise -> on hass.states.async_set("light.test1", STATE_ON) hass.states.async_set("light.test2", STATE_ON) await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_ON - hass.states.async_set("light.test1", STATE_UNAVAILABLE) - hass.states.async_set("light.test2", STATE_UNAVAILABLE) + # All group members removed from the state machine -> unavailable + hass.states.async_remove("light.test1") + hass.states.async_remove("light.test2") await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index 8db28fab18e..4b12bcfbd7c 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -57,7 +57,16 @@ async def test_default_state(hass): async def test_state_reporting(hass): - """Test the state reporting.""" + """Test the state reporting. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is jammed if at least one group member is jammed. + Otherwise, the group state is locking if at least one group member is locking. + Otherwise, the group state is unlocking if at least one group member is unlocking. + Otherwise, the group state is unlocked if at least one group member is unlocked. + Otherwise, the group state is locked. + """ await async_setup_component( hass, LOCK_DOMAIN, @@ -72,43 +81,88 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("lock.test1", STATE_LOCKED) + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE + + # All group members unavailable -> unavailable + hass.states.async_set("lock.test1", STATE_UNAVAILABLE) hass.states.async_set("lock.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNKNOWN + assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE - hass.states.async_set("lock.test1", STATE_LOCKED) - hass.states.async_set("lock.test2", STATE_UNLOCKED) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNKNOWN + # At least one member jammed -> group jammed + for state_1 in ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_JAMMED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_JAMMED + + # At least one member locking -> group unlocking + for state_1 in ( + STATE_LOCKED, + STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_LOCKING) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_LOCKING + + # At least one member unlocking -> group unlocking + for state_1 in ( + STATE_LOCKED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_UNLOCKING) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKING + + # At least one member unlocked -> group unlocked + for state_1 in ( + STATE_LOCKED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_UNLOCKED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + + # Otherwise -> locked hass.states.async_set("lock.test1", STATE_LOCKED) hass.states.async_set("lock.test2", STATE_LOCKED) await hass.async_block_till_done() assert hass.states.get("lock.lock_group").state == STATE_LOCKED - hass.states.async_set("lock.test1", STATE_UNLOCKED) - hass.states.async_set("lock.test2", STATE_UNLOCKED) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED - - hass.states.async_set("lock.test1", STATE_UNLOCKED) - hass.states.async_set("lock.test2", STATE_JAMMED) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_JAMMED - - hass.states.async_set("lock.test1", STATE_LOCKED) - hass.states.async_set("lock.test2", STATE_UNLOCKING) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKING - - hass.states.async_set("lock.test1", STATE_UNLOCKED) - hass.states.async_set("lock.test2", STATE_LOCKING) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_LOCKING - - hass.states.async_set("lock.test1", STATE_UNAVAILABLE) - hass.states.async_set("lock.test2", STATE_UNAVAILABLE) + # All group members removed from the state machine -> unavailable + hass.states.async_remove("lock.test1") + hass.states.async_remove("lock.test2") await hass.async_block_till_done() assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index f741e2d1a84..5d0d52ae3ac 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -43,6 +43,8 @@ from homeassistant.const import ( SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, + STATE_BUFFERING, + STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, @@ -99,7 +101,17 @@ async def test_default_state(hass): async def test_state_reporting(hass): - """Test the state reporting.""" + """Test the state reporting. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is buffering if all group members are buffering. + Otherwise, the group state is idle if all group members are idle. + Otherwise, the group state is paused if all group members are paused. + Otherwise, the group state is playing if all group members are playing. + Otherwise, the group state is on if at least one group member is not off, unavailable or unknown. + Otherwise, the group state is off. + """ await async_setup_component( hass, MEDIA_DOMAIN, @@ -114,28 +126,65 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_UNKNOWN - - hass.states.async_set("media_player.player_1", STATE_ON) - hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_ON - - hass.states.async_set("media_player.player_1", STATE_ON) - hass.states.async_set("media_player.player_2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_ON - - hass.states.async_set("media_player.player_1", STATE_OFF) - hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_OFF + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("media_player.media_group").state == STATE_UNAVAILABLE + # All group members unavailable -> unavailable hass.states.async_set("media_player.player_1", STATE_UNAVAILABLE) hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("media_player.media_group").state == STATE_UNAVAILABLE + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_UNKNOWN + + # All group members buffering -> buffering + # All group members idle -> idle + # All group members paused -> paused + # All group members playing -> playing + # All group members unavailable -> unavailable + # All group members unknown -> unknown + for state in ( + STATE_BUFFERING, + STATE_IDLE, + STATE_PAUSED, + STATE_PLAYING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set("media_player.player_1", state) + hass.states.async_set("media_player.player_2", state) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == state + + # At least one member not off, unavailable or unknown -> on + for state_1 in (STATE_BUFFERING, STATE_IDLE, STATE_ON, STATE_PAUSED, STATE_PLAYING): + for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", state_2) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_ON + + # Otherwise off + for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_OFF + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("media_player.player_1") + hass.states.async_remove("media_player.player_2") + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_UNAVAILABLE + async def test_supported_features(hass): """Test supported features reporting.""" diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index fb68d9d3d43..7a4a41839ef 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import group from homeassistant.components.group import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_ON from homeassistant.core import State diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 5df2542d101..9a8da274a0a 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -56,7 +56,13 @@ async def test_default_state(hass): async def test_state_reporting(hass): - """Test the state reporting.""" + """Test the state reporting in 'any' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is on if at least one group member is on. + Otherwise, the group state is off. + """ await async_setup_component( hass, SWITCH_DOMAIN, @@ -72,29 +78,79 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("switch.test1", STATE_ON) - hass.states.async_set("switch.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("switch.switch_group").state == STATE_ON - - hass.states.async_set("switch.test1", STATE_ON) - hass.states.async_set("switch.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("switch.switch_group").state == STATE_ON - - hass.states.async_set("switch.test1", STATE_OFF) - hass.states.async_set("switch.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("switch.switch_group").state == STATE_OFF + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + # All group members unavailable -> unavailable hass.states.async_set("switch.test1", STATE_UNAVAILABLE) hass.states.async_set("switch.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + # All group members unknown -> unknown + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + # Group members unknown or unavailable -> unknown + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + # At least one member on -> group on + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + # Otherwise -> off + hass.states.async_set("switch.test1", STATE_OFF) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_OFF + + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_OFF + + hass.states.async_set("switch.test1", STATE_UNAVAILABLE) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_OFF + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("switch.test1") + hass.states.async_remove("switch.test2") + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + async def test_state_reporting_all(hass): - """Test the state reporting.""" + """Test the state reporting in 'all' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is off if at least one group member is off. + Otherwise, the group state is on. + """ await async_setup_component( hass, SWITCH_DOMAIN, @@ -110,11 +166,47 @@ async def test_state_reporting_all(hass): await hass.async_start() await hass.async_block_till_done() + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + + # All group members unavailable -> unavailable + hass.states.async_set("switch.test1", STATE_UNAVAILABLE) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + + # At least one member unknown or unavailable -> group unknown hass.states.async_set("switch.test1", STATE_ON) hass.states.async_set("switch.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_OFF) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_OFF) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + # At least one member off -> group off hass.states.async_set("switch.test1", STATE_ON) hass.states.async_set("switch.test2", STATE_OFF) await hass.async_block_till_done() @@ -125,13 +217,15 @@ async def test_state_reporting_all(hass): await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_OFF + # Otherwise -> on hass.states.async_set("switch.test1", STATE_ON) hass.states.async_set("switch.test2", STATE_ON) await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_ON - hass.states.async_set("switch.test1", STATE_UNAVAILABLE) - hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + # All group members removed from the state machine -> unavailable + hass.states.async_remove("switch.test1") + hass.states.async_remove("switch.test2") await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE diff --git a/tests/components/hassio/__init__.py b/tests/components/hassio/__init__.py index 79520c6fd12..76aecd64098 100644 --- a/tests/components/hassio/__init__.py +++ b/tests/components/hassio/__init__.py @@ -1,2 +1,2 @@ """Tests for Hass.io component.""" -HASSIO_TOKEN = "123456" +SUPERVISOR_TOKEN = "123456" diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 89a8c6f5c51..a6cd956c95e 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -9,16 +9,16 @@ from homeassistant.core import CoreState from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component -from . import HASSIO_TOKEN +from . import SUPERVISOR_TOKEN @pytest.fixture def hassio_env(): """Fixture to inject hassio env.""" - with patch.dict(os.environ, {"HASSIO": "127.0.0.1"}), patch( + with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value={"result": "ok", "data": {}}, - ), patch.dict(os.environ, {"HASSIO_TOKEN": HASSIO_TOKEN}), patch( + ), patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}), patch( "homeassistant.components.hassio.HassIO.get_info", Mock(side_effect=HassioAPIError()), ): @@ -75,5 +75,5 @@ def hassio_handler(hass, aioclient_mock): websession = hass.loop.run_until_complete(get_client_session()) - with patch.dict(os.environ, {"HASSIO_TOKEN": HASSIO_TOKEN}): + with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): yield HassIO(hass.loop, websession, "127.0.0.1") diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index 0f4691e2795..ba9bcb2afdf 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 7bbc768681a..1f915e17e61 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -13,7 +13,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 60fea96d4ea..34016fa9052 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO import pytest +from homeassistant.components.hassio.const import X_AUTH_TOKEN + @pytest.mark.parametrize( "build_type", @@ -35,7 +37,7 @@ async def test_ingress_request_get(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -75,7 +77,7 @@ async def test_ingress_request_post(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -115,7 +117,7 @@ async def test_ingress_request_put(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -155,7 +157,7 @@ async def test_ingress_request_delete(hassio_client, build_type, aioclient_mock) # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -195,7 +197,7 @@ async def test_ingress_request_patch(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -235,7 +237,7 @@ async def test_ingress_request_options(hassio_client, build_type, aioclient_mock # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -268,7 +270,7 @@ async def test_ingress_websocket(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ff595aaa602..60fec517aa9 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,16 +17,22 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture() -def os_info(): +def extra_os_info(): + """Extra os/info.""" + return {} + + +@pytest.fixture() +def os_info(extra_os_info): """Mock os/info.""" return { "json": { "result": "ok", - "data": {"version_latest": "1.0.0", "version": "1.0.0"}, + "data": {"version_latest": "1.0.0", "version": "1.0.0", **extra_os_info}, } } @@ -336,13 +342,13 @@ async def test_setup_core_push_timezone(hass, aioclient_mock): async def test_setup_hassio_no_additional_data(hass, aioclient_mock): """Test setup with API push default data.""" with patch.dict(os.environ, MOCK_ENVIRON), patch.dict( - os.environ, {"HASSIO_TOKEN": "123456"} + os.environ, {"SUPERVISOR_TOKEN": "123456"} ): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result assert aioclient_mock.call_count == 15 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" async def test_fail_setup_without_environ_var(hass): @@ -715,21 +721,25 @@ async def test_coordinator_updates(hass, caplog): @pytest.mark.parametrize( - "os_info", + "extra_os_info, integration", [ - { - "json": { - "result": "ok", - "data": {"version_latest": "1.0.0", "version": "1.0.0", "board": "rpi"}, - } - } + ({"board": "odroid-c2"}, "hardkernel"), + ({"board": "odroid-c4"}, "hardkernel"), + ({"board": "odroid-n2"}, "hardkernel"), + ({"board": "odroid-xu4"}, "hardkernel"), + ({"board": "rpi2"}, "raspberry_pi"), + ({"board": "rpi3"}, "raspberry_pi"), + ({"board": "rpi3-64"}, "raspberry_pi"), + ({"board": "rpi4"}, "raspberry_pi"), + ({"board": "rpi4-64"}, "raspberry_pi"), + ({"board": "yellow"}, "homeassistant_yellow"), ], ) -async def test_setup_hardware_integration(hass, aioclient_mock): +async def test_setup_hardware_integration(hass, aioclient_mock, integration): """Test setup initiates hardware integration.""" with patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.raspberry_pi.async_setup_entry", + f"homeassistant.components.{integration}.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await async_setup_component(hass, "hassio", {"hassio": {}}) diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 382d804eaac..868448cec2d 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 14d1d06ef38..48f6d894de0 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -12,7 +12,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 8c0a80719a8..5eb4894c72a 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -753,7 +753,7 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( { "history": { "exclude": { - "entity_globs": ["light.many*"], + "entity_globs": ["light.many*", "binary_sensor.*"], }, "include": { "entity_globs": ["light.m*"], @@ -769,6 +769,7 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( hass.states.async_set("light.many_state_changes", "on") hass.states.async_set("switch.match", "on") hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.exclude", "on") await async_wait_recording_done(hass) @@ -778,10 +779,11 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( ) assert response.status == HTTPStatus.OK response_json = await response.json() - assert len(response_json) == 3 - assert response_json[0][0]["entity_id"] == "light.match" - assert response_json[1][0]["entity_id"] == "media_player.test" - assert response_json[2][0]["entity_id"] == "switch.match" + assert len(response_json) == 4 + assert response_json[0][0]["entity_id"] == "light.many_state_changes" + assert response_json[1][0]["entity_id"] == "light.match" + assert response_json[2][0]["entity_id"] == "media_player.test" + assert response_json[3][0]["entity_id"] == "switch.match" async def test_entity_ids_limit_via_api(hass, hass_client, recorder_mock): @@ -994,7 +996,7 @@ async def test_statistics_during_period_in_the_past( assert response["success"] assert response["result"] == {} - past = now - timedelta(days=3) + past = now - timedelta(days=3, hours=1) await client.send_json( { "id": 3, diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index f824ee552ca..8907f381a6c 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config as hass_config from homeassistant.components.history_stats import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component, setup_component @@ -50,7 +50,7 @@ class TestHistoryStatsSensor(unittest.TestCase): self.hass.block_till_done() state = self.hass.states.get("sensor.test") - assert state.state == STATE_UNKNOWN + assert state.state == "0.0" def test_setup_multiple_states(self): """Test the history statistics sensor setup for multiple states.""" @@ -71,7 +71,7 @@ class TestHistoryStatsSensor(unittest.TestCase): self.hass.block_till_done() state = self.hass.states.get("sensor.test") - assert state.state == STATE_UNKNOWN + assert state.state == "0.0" def test_wrong_duration(self): """Test when duration value is not a timedelta.""" @@ -153,12 +153,12 @@ async def test_invalid_date_for_start(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_invalid_date_for_end(hass, recorder_mock): @@ -178,12 +178,12 @@ async def test_invalid_date_for_end(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_invalid_entity_in_template(hass, recorder_mock): @@ -203,12 +203,12 @@ async def test_invalid_entity_in_template(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): @@ -228,12 +228,12 @@ async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_reload(hass, recorder_mock): @@ -302,56 +302,55 @@ async def test_measure_multiple(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "input_select.test_id", - "name": "sensor1", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "unknown.test_id", - "name": "sensor2", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "input_select.test_id", - "name": "sensor3", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "input_select.test_id", - "name": "sensor4", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "input_select.test_id", + "name": "sensor1", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "unknown.test_id", + "name": "sensor2", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "input_select.test_id", + "name": "sensor3", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "input_select.test_id", + "name": "sensor4", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -382,56 +381,55 @@ async def test_measure(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor1", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor2", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor3", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor4", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -463,56 +461,55 @@ async def test_async_on_entire_period(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor1", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor2", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor3", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor4", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.on_sensor{i}") await hass.async_block_till_done() @@ -1235,56 +1232,55 @@ async def test_measure_from_end_going_backwards(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor1", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor2", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor3", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor4", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ), freeze_time(start_time): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor1", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor2", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor3", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor4", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -1329,56 +1325,55 @@ async def test_measure_cet(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor1", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor2", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor3", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor4", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index 35e20e8eee3..e6e2a06501a 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from apyhiveapi.helper import hive_exceptions from homeassistant import config_entries, data_entry_flow -from homeassistant.components.hive.const import CONF_CODE, DOMAIN +from homeassistant.components.hive.const import CONF_CODE, CONF_DEVICE_NAME, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from tests.common import MockConfigEntry @@ -16,6 +16,7 @@ UPDATED_PASSWORD = "updated-password" INCORRECT_PASSWORD = "incorrect-password" SCAN_INTERVAL = 120 UPDATED_SCAN_INTERVAL = 60 +DEVICE_NAME = "Test Home Assistant" MFA_CODE = "1234" MFA_RESEND_CODE = "0000" MFA_INVALID_CODE = "HIVE" @@ -148,11 +149,23 @@ async def test_user_flow_2fa(hass): "AccessToken": "mock-access-token", }, }, - ), patch( + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: MFA_CODE, + }, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["step_id"] == "configuration" + assert result3["errors"] == {} + + with patch( "homeassistant.components.hive.config_flow.Auth.device_registration", return_value=True, ), patch( - "homeassistant.components.hive.config_flow.Auth.getDeviceData", + "homeassistant.components.hive.config_flow.Auth.get_device_data", return_value=[ "mock-device-group-key", "mock-device-key", @@ -164,14 +177,17 @@ async def test_user_flow_2fa(hass): "homeassistant.components.hive.async_setup_entry", return_value=True, ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {CONF_CODE: MFA_CODE} + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_NAME: DEVICE_NAME, + }, ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == USERNAME - assert result3["data"] == { + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result4["title"] == USERNAME + assert result4["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -235,9 +251,6 @@ async def test_reauth_flow(hass): "AccessToken": "mock-access-token", }, }, - ), patch( - "homeassistant.components.hive.config_flow.Auth.device_registration", - return_value=True, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -255,6 +268,82 @@ async def test_reauth_flow(hass): assert len(hass.config_entries.async_entries(DOMAIN)) == 1 +async def test_reauth_2fa_flow(hass): + """Test the reauth flow.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=USERNAME, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: INCORRECT_PASSWORD, + "tokens": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + }, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + side_effect=hive_exceptions.HiveInvalidPassword(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_password"} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: UPDATED_PASSWORD, + }, + ) + + with patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_CODE: MFA_CODE, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get("username") == USERNAME + assert mock_config.data.get("password") == UPDATED_PASSWORD + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + async def test_option_flow(hass): """Test config flow options.""" @@ -343,11 +432,23 @@ async def test_user_flow_2fa_send_new_code(hass): "AccessToken": "mock-access-token", }, }, - ), patch( + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: MFA_CODE, + }, + ) + + assert result4["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result4["step_id"] == "configuration" + assert result4["errors"] == {} + + with patch( "homeassistant.components.hive.config_flow.Auth.device_registration", return_value=True, ), patch( - "homeassistant.components.hive.config_flow.Auth.getDeviceData", + "homeassistant.components.hive.config_flow.Auth.get_device_data", return_value=[ "mock-device-group-key", "mock-device-key", @@ -359,14 +460,14 @@ async def test_user_flow_2fa_send_new_code(hass): "homeassistant.components.hive.async_setup_entry", return_value=True, ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], {CONF_CODE: MFA_CODE} + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], {CONF_DEVICE_NAME: DEVICE_NAME} ) await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"] == USERNAME - assert result4["data"] == { + assert result5["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result5["title"] == USERNAME + assert result5["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -610,7 +711,28 @@ async def test_user_flow_2fa_unknown_error(hass): result2["flow_id"], {CONF_CODE: MFA_CODE}, ) - await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["errors"] == {"base": "unknown"} + assert result3["step_id"] == "configuration" + assert result3["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, + ), patch( + "homeassistant.components.hive.config_flow.Auth.get_device_data", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE_NAME: DEVICE_NAME}, + ) + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result4["step_id"] == "configuration" + assert result4["errors"] == {"base": "unknown"} diff --git a/tests/components/homeassistant_yellow/__init__.py b/tests/components/homeassistant_yellow/__init__.py new file mode 100644 index 00000000000..a03eed7b9b2 --- /dev/null +++ b/tests/components/homeassistant_yellow/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Yellow integration.""" diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py new file mode 100644 index 00000000000..8700e361dc8 --- /dev/null +++ b/tests/components/homeassistant_yellow/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for the Home Assistant Yellow integration.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_zha(): + """Mock the zha integration.""" + with patch( + "homeassistant.components.zha.async_setup_entry", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py new file mode 100644 index 00000000000..2e96b05a919 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -0,0 +1,58 @@ +"""Test the Home Assistant Yellow config flow.""" +from unittest.mock import patch + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test the config flow.""" + mock_integration(hass, MockModule("hassio")) + + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home Assistant Yellow" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == {} + assert config_entry.title == "Home Assistant Yellow" + + +async def test_config_flow_single_entry(hass: HomeAssistant) -> None: + """Test only a single entry is allowed.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + mock_setup_entry.assert_not_called() diff --git a/tests/components/homeassistant_yellow/test_hardware.py b/tests/components/homeassistant_yellow/test_hardware.py new file mode 100644 index 00000000000..28403334ec1 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_hardware.py @@ -0,0 +1,89 @@ +"""Test the Home Assistant Yellow hardware platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get the board info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value={"board": "yellow"}, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == { + "hardware": [ + { + "board": { + "hassio_board_id": "yellow", + "manufacturer": "homeassistant", + "model": "yellow", + "revision": None, + }, + "name": "Home Assistant Yellow", + "url": None, + } + ] + } + + +@pytest.mark.parametrize("os_info", [None, {"board": None}, {"board": "other"}]) +async def test_hardware_info_fail(hass: HomeAssistant, hass_ws_client, os_info) -> None: + """Test async_info raises if os_info is not as expected.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value=os_info, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == {"hardware": []} diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py new file mode 100644 index 00000000000..f534c7cd587 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_init.py @@ -0,0 +1,119 @@ +"""Test the Home Assistant Yellow integration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +@pytest.mark.parametrize( + "onboarded, num_entries, num_flows", ((False, 1, 0), (True, 0, 1)) +) +async def test_setup_entry( + hass: HomeAssistant, onboarded, num_entries, num_flows +) -> None: + """Test setup of a config entry, including setup of zha.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ) as mock_get_os_info, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=onboarded + ): + 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(hass.config_entries.async_entries("zha")) == num_entries + assert len(hass.config_entries.flow.async_progress_by_handler("zha")) == num_flows + + +async def test_setup_zha(hass: HomeAssistant) -> None: + """Test zha gets the right config.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ) as mock_get_os_info, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + 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 + + config_entry = hass.config_entries.async_entries("zha")[0] + assert config_entry.data == { + "device": { + "baudrate": 115200, + "flow_control": "hardware", + "path": "/dev/ttyAMA1", + }, + "radio_type": "ezsp", + } + assert config_entry.options == {} + assert config_entry.title == "Yellow" + + +async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: + """Test setup of a config entry with wrong board type.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + 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 + + +async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: + """Test setup of a config entry when hassio has not fetched os_info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value=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 diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9a8b284b97e..83afcedd839 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -642,11 +642,15 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): assert char assert char.value is True + broker = MagicMock() + char.broker = broker hass.states.async_set( motion_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() + assert len(broker.mock_calls) == 2 + broker.reset_mock() assert char.value is False char.set_value(True) @@ -654,8 +658,28 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() + assert len(broker.mock_calls) == 2 + broker.reset_mock() assert char.value is True + hass.states.async_set( + motion_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION}, + force_update=True, + ) + await hass.async_block_till_done() + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + hass.states.async_set( + motion_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION, "other": "attr"}, + ) + await hass.async_block_till_done() + assert len(broker.mock_calls) == 0 + broker.reset_mock() # Ensure we do not throw when the linked # motion sensor is removed hass.states.async_remove(motion_entity_id) @@ -747,7 +771,8 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): assert service2 char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) assert char2 - + broker = MagicMock() + char2.broker = broker assert char2.value is None hass.states.async_set( @@ -758,9 +783,12 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): await hass.async_block_till_done() assert char.value is None assert char2.value is None + assert len(broker.mock_calls) == 0 char.set_value(True) char2.set_value(True) + broker.reset_mock() + hass.states.async_set( doorbell_entity_id, STATE_ON, @@ -769,6 +797,31 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): await hass.async_block_till_done() assert char.value is None assert char2.value is None + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, + force_update=True, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY, "other": "attr"}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() # Ensure we do not throw when the linked # doorbell sensor is removed diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 8f59fae8639..749bd4b0f07 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -372,3 +372,17 @@ async def assert_devices_and_entities_created( # Root device must not have a via, otherwise its not the device assert root_device.via_device_id is None + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 03694e7186a..820b89e587d 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -8,9 +8,17 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.homekit_controller.const import ENTITY_MAP from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component + +from .common import Helper, remove_device from tests.components.homekit_controller.common import setup_test_component +ALIVE_DEVICE_NAME = "Light Bulb" +ALIVE_DEEVICE_ENTITY_ID = "light.testdevice" + def create_motion_sensor_service(accessory): """Define motion characteristics as per page 225 of HAP spec.""" @@ -47,3 +55,37 @@ async def test_async_remove_entry(hass: HomeAssistant): assert len(controller.pairings) == 0 assert hkid not in hass.data[ENTITY_MAP].storage_data + + +def create_alive_service(accessory): + """Create a service to validate we can only remove dead devices.""" + service = accessory.add_service(ServicesTypes.LIGHTBULB, name=ALIVE_DEVICE_NAME) + service.add_char(CharacteristicsTypes.ON) + return service + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + helper: Helper = await setup_test_component(hass, create_alive_service) + config_entry = helper.config_entry + entry_id = config_entry.entry_id + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities[ALIVE_DEEVICE_ENTITY_ID] + device_registry = dr.async_get(hass) + + live_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("homekit_controller:accessory-id", "E9:88:E7:B8:B4:40:aid:1")}, + ) + assert ( + await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) + is True + ) diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index a35576ed353..023ba9d5f0e 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -109,7 +109,7 @@ async def test_hmip_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 1 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0, 1) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) ha_state = hass.states.get(entity_id) @@ -125,7 +125,7 @@ async def test_hmip_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 4 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0.5, 1) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN @@ -137,7 +137,7 @@ async def test_hmip_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 6 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (1, 1) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN @@ -187,7 +187,7 @@ async def test_hmip_multi_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 1 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0, 4) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0, channel=4) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0, channel=4) ha_state = hass.states.get(entity_id) @@ -203,7 +203,7 @@ async def test_hmip_multi_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 4 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0.5, 4) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5, channel=4) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN @@ -215,7 +215,7 @@ async def test_hmip_multi_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 6 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (1, 4) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1, channel=4) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index d2e7d4c58ae..fca00b71892 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -2,7 +2,7 @@ import logging from unittest.mock import patch -from homewizard_energy.errors import DisabledError, UnsupportedError +from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError from homeassistant import config_entries from homeassistant.components import zeroconf @@ -333,3 +333,31 @@ async def test_check_detects_invalid_api(hass, aioclient_mock): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "unsupported_api_version" + + +async def test_check_requesterror(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data due to a requesterror.""" + + def mock_initialize(): + raise RequestError + + device = get_mock_device() + device.device.side_effect = mock_initialize + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown_error" diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 4a2e1e8aed3..a06a696e994 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -17,6 +17,7 @@ from homeassistant.components import websocket_api from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, + SIGN_QUERY_PARAM, STORAGE_KEY, async_setup_auth, async_sign_path, @@ -294,6 +295,108 @@ async def test_auth_access_signed_path_with_refresh_token( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_with_query_param( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and query params.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, "/?test=test", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +async def test_auth_access_signed_path_with_query_param_order( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and query params different order.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, + "/?test=test&foo=bar", + timedelta(seconds=5), + refresh_token_id=refresh_token.id, + ) + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +async def test_auth_access_signed_path_with_query_param_safe_param( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and changing a safe param.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, + "/?test=test&foo=bar", + timedelta(seconds=5), + refresh_token_id=refresh_token.id, + ) + signed_path = f"{signed_path}&width=100" + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +@pytest.mark.parametrize( + "base_url,test_url", + [ + ("/?test=test", "/?test=test&foo=bar"), + ("/", "/?test=test"), + ("/?test=test&foo=bar", "/?test=test&foo=baz"), + ("/?test=test&foo=bar", "/?test=test"), + ], +) +async def test_auth_access_signed_path_with_query_param_tamper( + hass, app, aiohttp_client, hass_access_token, base_url: str, test_url: str +): + """Test access with signed url and query params that have been tampered with.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, base_url, timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + url = yarl.URL(signed_path) + token = url.query.get(SIGN_QUERY_PARAM) + + req = await client.get(f"{test_url}&{SIGN_QUERY_PARAM}={token}") + assert req.status == HTTPStatus.UNAUTHORIZED + + async def test_auth_access_signed_path_via_websocket( hass, app, hass_ws_client, hass_read_only_access_token ): diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index fbd545e0506..5e482d16248 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -33,10 +33,10 @@ BANNED_IPS_WITH_SUPERVISOR = BANNED_IPS + [SUPERVISOR_IP] @pytest.fixture(name="hassio_env") def hassio_env_fixture(): """Fixture to inject hassio env.""" - with patch.dict(os.environ, {"HASSIO": "127.0.0.1"}), patch( + with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value={"result": "ok", "data": {}}, - ), patch.dict(os.environ, {"HASSIO_TOKEN": "123456"}): + ), patch.dict(os.environ, {"SUPERVISOR_TOKEN": "123456"}): yield diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index fdd4fbc7808..f6a2ff85d3a 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -1,5 +1,6 @@ """Tests for Home Assistant View.""" from http import HTTPStatus +import json from unittest.mock import AsyncMock, Mock from aiohttp.web_exceptions import ( @@ -34,9 +35,16 @@ async def test_invalid_json(caplog): view = HomeAssistantView() with pytest.raises(HTTPInternalServerError): - view.json(float("NaN")) + view.json(rb"\ud800") - assert str(float("NaN")) in caplog.text + assert "Unable to serialize to JSON" in caplog.text + + +async def test_nan_serialized_to_null(caplog): + """Test nan serialized to null JSON.""" + view = HomeAssistantView() + response = view.json(float("NaN")) + assert json.loads(response.body.decode("utf-8")) is None async def test_handling_unauthorized(mock_request): diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index ce694fc221b..28859e6133f 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -9,7 +9,7 @@ from homeassistant.components.humidifier import ( ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/ialarm_xr/__init__.py b/tests/components/ialarm_xr/__init__.py deleted file mode 100644 index 4097867f70b..00000000000 --- a/tests/components/ialarm_xr/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Antifurto365 iAlarmXR integration.""" diff --git a/tests/components/ialarm_xr/test_config_flow.py b/tests/components/ialarm_xr/test_config_flow.py deleted file mode 100644 index 804249dd5cb..00000000000 --- a/tests/components/ialarm_xr/test_config_flow.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Test the Antifurto365 iAlarmXR config flow.""" - -from unittest.mock import patch - -from pyialarmxr import IAlarmXRGenericException, IAlarmXRSocketTimeoutException - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.ialarm_xr.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME - -from tests.common import MockConfigEntry - -TEST_DATA = { - CONF_HOST: "1.1.1.1", - CONF_PORT: 18034, - CONF_USERNAME: "000ZZZ0Z00", - CONF_PASSWORD: "00000000", -} - -TEST_MAC = "00:00:54:12:34:56" - - -async def test_form(hass): - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["handler"] == "ialarm_xr" - assert result["data_schema"].schema.get("host") == str - assert result["data_schema"].schema.get("port") == int - assert result["data_schema"].schema.get("password") == str - assert result["data_schema"].schema.get("username") == str - assert result["errors"] == {} - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_status", - return_value=1, - ), patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - return_value=TEST_MAC, - ), patch( - "homeassistant.components.ialarm_xr.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == TEST_DATA["host"] - assert result2["data"] == TEST_DATA - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_exception(hass): - """Test we handle unknown exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_form_cannot_connect_throwing_connection_error(hass): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=ConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_cannot_connect_throwing_socket_timeout_exception(hass): - """Test we handle cannot connect error because of socket timeout.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=IAlarmXRSocketTimeoutException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "timeout"} - - -async def test_form_cannot_connect_throwing_generic_exception(hass): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=IAlarmXRGenericException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_already_exists(hass): - """Test that a flow with an existing host aborts.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_MAC, - data=TEST_DATA, - ) - - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - return_value=TEST_MAC, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result2["reason"] == "already_configured" - - -async def test_flow_user_step_no_input(hass): - """Test appropriate error when no input is provided.""" - _result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - _result["flow_id"], user_input=None - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"] == {} diff --git a/tests/components/ialarm_xr/test_init.py b/tests/components/ialarm_xr/test_init.py deleted file mode 100644 index 0898b6bebf8..00000000000 --- a/tests/components/ialarm_xr/test_init.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Test the Antifurto365 iAlarmXR init.""" -import asyncio -from datetime import timedelta -from unittest.mock import Mock, patch -from uuid import uuid4 - -import pytest - -from homeassistant.components.ialarm_xr.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.util.dt import utcnow - -from tests.common import MockConfigEntry, async_fire_time_changed - - -@pytest.fixture(name="ialarmxr_api") -def ialarmxr_api_fixture(): - """Set up IAlarmXR API fixture.""" - with patch("homeassistant.components.ialarm_xr.IAlarmXR") as mock_ialarm_api: - yield mock_ialarm_api - - -@pytest.fixture(name="mock_config_entry") -def mock_config_fixture(): - """Return a fake config entry.""" - return MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "192.168.10.20", - CONF_PORT: 18034, - CONF_USERNAME: "000ZZZ0Z00", - CONF_PASSWORD: "00000000", - }, - entry_id=str(uuid4()), - ) - - -async def test_setup_entry(hass, ialarmxr_api, mock_config_entry): - """Test setup entry.""" - ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - ialarmxr_api.return_value.get_mac.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.LOADED - - -async def test_unload_entry(hass, ialarmxr_api, mock_config_entry): - """Test being able to unload an entry.""" - ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_setup_not_ready_connection_error(hass, ialarmxr_api, mock_config_entry): - """Test setup failed because we can't connect to the alarm system.""" - ialarmxr_api.return_value.get_status = Mock(side_effect=ConnectionError) - - mock_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - future = utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_not_ready_timeout(hass, ialarmxr_api, mock_config_entry): - """Test setup failed because we can't connect to the alarm system.""" - ialarmxr_api.return_value.get_status = Mock(side_effect=asyncio.TimeoutError) - - mock_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - future = utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_entry_and_then_fail_on_update( - hass, ialarmxr_api, mock_config_entry -): - """Test setup entry.""" - ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") - ialarmxr_api.return_value.get_status = Mock(value=ialarmxr_api.DISARMED) - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - ialarmxr_api.return_value.get_mac.assert_called_once() - ialarmxr_api.return_value.get_status.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.LOADED - - ialarmxr_api.return_value.get_status = Mock(side_effect=asyncio.TimeoutError) - future = utcnow() + timedelta(seconds=60) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - ialarmxr_api.return_value.get_status.assert_called_once() - assert hass.states.get("alarm_control_panel.ialarm_xr").state == "unavailable" diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 94071e849c2..27b9ac82ade 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -866,7 +866,7 @@ async def test_event_listener_filtered_allowlist( FilterTest("fake.excluded", False), FilterTest("another_fake.denied", False), FilterTest("fake.excluded_entity", False), - FilterTest("another_fake.included_entity", False), + FilterTest("another_fake.included_entity", True), ] execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index c01c2532953..e7f68379343 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_boolean import DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index eb5bcc05cf3..e469536549a 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_button import DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index e8da8939ea9..bbdd0446e56 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_datetime import CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index ca496723d99..4149627720b 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -255,6 +255,29 @@ async def test_restore_state(hass): assert float(state.state) == 10 +async def test_restore_invalid_state(hass): + """Ensure an invalid restore state is handled.""" + mock_restore_cache( + hass, (State("input_number.b1", "="), State("input_number.b2", "200")) + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"b1": {"min": 2, "max": 100}, "b2": {"min": 10, "max": 100}}}, + ) + + state = hass.states.get("input_number.b1") + assert state + assert float(state.state) == 2 + + state = hass.states.get("input_number.b2") + assert state + assert float(state.state) == 10 + + async def test_initial_state_overrules_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache( diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index 9db2e2cd9c8..f736d450e7a 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -10,7 +10,7 @@ from homeassistant.components.input_number import ( ATTR_STEP, DOMAIN, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index 3a5ae4e385f..2931132bafc 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_select import ATTR_OPTIONS, DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index f613bbcebe1..928399cd939 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -11,7 +11,7 @@ from homeassistant.components.input_text import ( DOMAIN, MODE_TEXT, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index 3f73834226c..8940acd9d8e 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -44,7 +44,7 @@ def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]: data_mock.serial = "12345" with patch( - "homeassistant.components.intellifire.config_flow.IntellifireAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPILocal", autospec=True, ) as intellifire_mock: intellifire = intellifire_mock.return_value diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index 2f48e645708..06fcbea5bfa 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -6,8 +6,8 @@ from intellifire4py.exceptions import LoginException from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.intellifire.config_flow import MANUAL_ENTRY_STRING -from homeassistant.components.intellifire.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.intellifire.const import CONF_USER_ID, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -20,9 +20,10 @@ from tests.components.intellifire.conftest import mock_api_connection_error @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", login=AsyncMock(), - get_username=AsyncMock(return_value="intellifire"), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_no_discovery( hass: HomeAssistant, @@ -64,14 +65,17 @@ async def test_no_discovery( CONF_HOST: "1.1.1.1", CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE", + CONF_API_KEY: "key", + CONF_USER_ID: "intellifire", } assert len(mock_setup_entry.mock_calls) == 1 @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", login=AsyncMock(side_effect=mock_api_connection_error()), - get_username=AsyncMock(return_value="intellifire"), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_single_discovery( hass: HomeAssistant, @@ -101,8 +105,10 @@ async def test_single_discovery( @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", - login=AsyncMock(side_effect=LoginException()), + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", + login=AsyncMock(side_effect=LoginException), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_single_discovery_loign_error( hass: HomeAssistant, @@ -265,9 +271,10 @@ async def test_picker_already_discovered( @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", login=AsyncMock(), - get_username=AsyncMock(return_value="intellifire"), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_reauth_flow( hass: HomeAssistant, diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 6f345522e63..39f865f4832 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -38,6 +38,8 @@ async def test_intent_script(hass): assert response.speech["plain"]["speech"] == "Good morning Paulus" + assert not (response.reprompt) + assert response.card["simple"]["title"] == "Hello Paulus" assert response.card["simple"]["content"] == "Content for Paulus" @@ -85,3 +87,49 @@ async def test_intent_script_wait_response(hass): assert response.card["simple"]["title"] == "Hello Paulus" assert response.card["simple"]["content"] == "Content for Paulus" + + +async def test_intent_script_falsy_reprompt(hass): + """Test intent scripts work.""" + calls = async_mock_service(hass, "test", "service") + + await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "HelloWorld": { + "action": { + "service": "test.service", + "data_template": {"hello": "{{ name }}"}, + }, + "card": { + "title": "Hello {{ name }}", + "content": "Content for {{ name }}", + }, + "speech": { + "type": "ssml", + "text": 'Good morning {{ name }}', + }, + "reprompt": {"text": "{{ null }}"}, + } + } + }, + ) + + response = await intent.async_handle( + hass, "test", "HelloWorld", {"name": {"value": "Paulus"}} + ) + + assert len(calls) == 1 + assert calls[0].data["hello"] == "Paulus" + + assert ( + response.speech["ssml"]["speech"] + == 'Good morning Paulus' + ) + + assert not (response.reprompt) + + assert response.card["simple"]["title"] == "Hello Paulus" + assert response.card["simple"]["content"] == "Content for Paulus" diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index 7ed1c4d3723..e6469043474 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -198,7 +198,7 @@ async def test_daily_forecast(hass): assert forecast.get(ATTR_FORECAST_TEMP) == 16.2 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6 assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == "100.0" - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "10" + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 10.0 assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" @@ -222,5 +222,5 @@ async def test_hourly_forecast(hass): assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy" assert forecast.get(ATTR_FORECAST_TEMP) == 7.7 assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 80.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "32.7" + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 32.7 assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 18d64842c65..730c5634770 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -59,19 +59,6 @@ async def test_options(hass): assert result["data"][CONF_CALC_METHOD] == "makkah" -async def test_import(hass): - """Test import step.""" - result = await hass.config_entries.flow.async_init( - islamic_prayer_times.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_CALC_METHOD: "makkah"}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Islamic Prayer Times" - assert result["data"][CONF_CALC_METHOD] == "makkah" - - async def test_integration_already_configured(hass): """Test integration is already configured.""" entry = MockConfigEntry( diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index e40af8c89ff..5a092373eef 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -9,7 +9,6 @@ import pytest from homeassistant import config_entries from homeassistant.components import islamic_prayer_times -from homeassistant.setup import async_setup_component from . import ( NEW_PRAYER_TIMES, @@ -28,22 +27,6 @@ def set_utc(hass): hass.config.set_time_zone("UTC") -async def test_setup_with_config(hass): - """Test that we import the config and setup the client.""" - config = { - islamic_prayer_times.DOMAIN: {islamic_prayer_times.CONF_CALC_METHOD: "isna"} - } - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, - ): - assert ( - await async_setup_component(hass, islamic_prayer_times.DOMAIN, config) - is True - ) - await hass.async_block_till_done() - - async def test_successful_config_entry(hass): """Test that Islamic Prayer Times is configured successfully.""" diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index 668b046df74..837d7624dae 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -1,6 +1,4 @@ """Test KNX number.""" -from unittest.mock import patch - import pytest from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS @@ -10,6 +8,8 @@ from homeassistant.core import HomeAssistant, State from .conftest import KNXTestKit +from tests.common import mock_restore_cache_with_extra_data + async def test_number_set_value(hass: HomeAssistant, knx: KNXTestKit): """Test KNX number with passive_address and respond_to_read restoring state.""" @@ -64,22 +64,28 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit): """Test KNX number with passive_address and respond_to_read restoring state.""" test_address = "1/1/1" test_passive_address = "3/3/3" - fake_state = State("number.test", "160") - with patch( - "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", - return_value=fake_state, - ): - await knx.setup_integration( - { - NumberSchema.PLATFORM: { - CONF_NAME: "test", - KNX_ADDRESS: [test_address, test_passive_address], - CONF_RESPOND_TO_READ: True, - CONF_TYPE: "illuminance", - } + RESTORE_DATA = { + "native_max_value": None, # Ignored by KNX number + "native_min_value": None, # Ignored by KNX number + "native_step": None, # Ignored by KNX number + "native_unit_of_measurement": None, # Ignored by KNX number + "native_value": 160.0, + } + + mock_restore_cache_with_extra_data( + hass, ((State("number.test", "abc"), RESTORE_DATA),) + ) + await knx.setup_integration( + { + NumberSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: [test_address, test_passive_address], + CONF_RESPOND_TO_READ: True, + CONF_TYPE: "illuminance", } - ) + } + ) # restored state - doesn't send telegram state = hass.states.get("number.test") assert state.state == "160.0" diff --git a/tests/components/knx/test_weather.py b/tests/components/knx/test_weather.py index 21d80248b97..c4a7c5de7a4 100644 --- a/tests/components/knx/test_weather.py +++ b/tests/components/knx/test_weather.py @@ -85,8 +85,8 @@ async def test_weather(hass: HomeAssistant, knx: KNXTestKit): state = hass.states.get("weather.test") assert state.attributes["temperature"] == 0.4 assert state.attributes["wind_bearing"] == 270 - assert state.attributes["wind_speed"] == 1.4400000000000002 - assert state.attributes["pressure"] == 980.5824 + assert state.attributes["wind_speed"] == 1.44 + assert state.attributes["pressure"] == 980.58 assert state.state is ATTR_CONDITION_SUNNY # update from KNX - set rain alarm diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py new file mode 100644 index 00000000000..f0fb42f6e78 --- /dev/null +++ b/tests/components/kostal_plenticore/test_number.py @@ -0,0 +1,197 @@ +"""Test Kostal Plenticore number.""" + +from unittest.mock import AsyncMock, MagicMock + +from kostal.plenticore import SettingsData +import pytest + +from homeassistant.components.kostal_plenticore.const import ( + PlenticoreNumberEntityDescription, +) +from homeassistant.components.kostal_plenticore.number import PlenticoreDataNumber +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_coordinator() -> MagicMock: + """Return a mocked coordinator for tests.""" + coordinator = MagicMock() + coordinator.async_write_data = AsyncMock() + coordinator.async_refresh = AsyncMock() + return coordinator + + +@pytest.fixture +def mock_number_description() -> PlenticoreNumberEntityDescription: + """Return a PlenticoreNumberEntityDescription for tests.""" + return PlenticoreNumberEntityDescription( + key="mock key", + module_id="moduleid", + data_id="dataid", + native_min_value=0, + native_max_value=1000, + fmt_from="format_round", + fmt_to="format_round_back", + ) + + +@pytest.fixture +def mock_setting_data() -> SettingsData: + """Return a default SettingsData for tests.""" + return SettingsData( + { + "default": None, + "min": None, + "access": None, + "max": None, + "unit": None, + "type": None, + "id": "data_id", + } + ) + + +async def test_setup_all_entries( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore: MagicMock +): + """Test if all available entries are setup up.""" + mock_plenticore.client.get_settings.return_value = { + "devices:local": [ + SettingsData({"id": "Battery:MinSoc", "min": None, "max": None}), + SettingsData( + {"id": "Battery:MinHomeComsumption", "min": None, "max": None} + ), + ] + } + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + ent_reg = async_get(hass) + assert ent_reg.async_get("number.scb_battery_min_soc") is not None + assert ent_reg.async_get("number.scb_battery_min_home_consumption") is not None + + +async def test_setup_no_entries( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore: MagicMock +): + """Test that no entries are setup up.""" + mock_plenticore.client.get_settings.return_value = [] + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + ent_reg = async_get(hass) + assert ent_reg.async_get("number.scb_battery_min_soc") is None + assert ent_reg.async_get("number.scb_battery_min_home_consumption") is None + + +def test_number_returns_value_if_available( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if value property on PlenticoreDataNumber returns an int if available.""" + + mock_coordinator.data = {"moduleid": {"dataid": "42"}} + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + assert entity.value == 42 + assert type(entity.value) == int + + +def test_number_returns_none_if_unavailable( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if value property on PlenticoreDataNumber returns none if unavailable.""" + + mock_coordinator.data = {} # makes entity not available + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + assert entity.value is None + + +async def test_set_value( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if set value calls coordinator with new value.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_set_native_value(42) + + mock_coordinator.async_write_data.assert_called_once_with( + "moduleid", {"dataid": "42"} + ) + mock_coordinator.async_refresh.assert_called_once() + + +async def test_minmax_overwrite( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, +): + """Test if min/max value is overwritten from retrieved settings data.""" + + setting_data = SettingsData( + { + "min": "5", + "max": "100", + } + ) + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, setting_data + ) + + assert entity.min_value == 5 + assert entity.max_value == 100 + + +async def test_added_to_hass( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if coordinator starts fetching after added to hass.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_added_to_hass() + + mock_coordinator.start_fetch_data.assert_called_once_with("moduleid", "dataid") + + +async def test_remove_from_hass( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if coordinator stops fetching after remove from hass.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_will_remove_from_hass() + + mock_coordinator.stop_fetch_data.assert_called_once_with("moduleid", "dataid") diff --git a/tests/components/lcn/fixtures/config.json b/tests/components/lcn/fixtures/config.json index cc615b6083b..13b3dd5feed 100644 --- a/tests/components/lcn/fixtures/config.json +++ b/tests/components/lcn/fixtures/config.json @@ -90,6 +90,47 @@ "address": "s0.m7", "motor": "motor1" } + ], + "binary_sensors": [ + { + "name": "Sensor_LockRegulator1", + "address": "s0.m7", + "source": "r1varsetpoint" + }, + { + "name": "Binary_Sensor1", + "address": "s0.m7", + "source": "binsensor1" + }, + { + "name": "Sensor_KeyLock", + "address": "s0.m7", + "source": "a5" + } + ], + "sensors": [ + { + "name": "Sensor_Var1", + "address": "s0.m7", + "source": "var1", + "unit_of_measurement": "°C" + }, + { + "name": "Sensor_Setpoint1", + "address": "s0.m7", + "source": "r1varsetpoint", + "unit_of_measurement": "°C" + }, + { + "name": "Sensor_Led6", + "address": "s0.m7", + "source": "led6" + }, + { + "name": "Sensor_LogicOp1", + "address": "s0.m7", + "source": "logicop1" + } ] } } diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 620bbb673f5..31b51adfce7 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -120,6 +120,73 @@ "motor": "MOTOR1", "reverse_time": "RT1200" } + }, + { + "address": [0, 7, false], + "name": "Sensor_LockRegulator1", + "resource": "r1varsetpoint", + "domain": "binary_sensor", + "domain_data": { + "source": "R1VARSETPOINT" + } + }, + { + "address": [0, 7, false], + "name": "Binary_Sensor1", + "resource": "binsensor1", + "domain": "binary_sensor", + "domain_data": { + "source": "BINSENSOR1" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_KeyLock", + "resource": "a5", + "domain": "binary_sensor", + "domain_data": { + "source": "A5" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Var1", + "resource": "var1", + "domain": "sensor", + "domain_data": { + "source": "VAR1", + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Setpoint1", + "resource": "r1varsetpoint", + "domain": "sensor", + "domain_data": { + "source": "R1VARSETPOINT", + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Led6", + "resource": "led6", + "domain": "sensor", + "domain_data": { + "source": "LED6", + "unit_of_measurement": "NATIVE" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_LogicOp1", + "resource": "logicop1", + "domain": "sensor", + "domain_data": { + "source": "LOGICOP1", + "unit_of_measurement": "NATIVE" + } } ] } diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py new file mode 100644 index 00000000000..3f9adb34295 --- /dev/null +++ b/tests/components/lcn/test_binary_sensor.py @@ -0,0 +1,140 @@ +"""Test for the LCN binary sensor platform.""" +from pypck.inputs import ModStatusBinSensors, ModStatusKeyLocks, ModStatusVar +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import Var, VarValue + +from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers import entity_registry as er + +BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.sensor_lockregulator1" +BINARY_SENSOR_SENSOR1 = "binary_sensor.binary_sensor1" +BINARY_SENSOR_KEYLOCK = "binary_sensor.sensor_keylock" + + +async def test_setup_lcn_binary_sensor(hass, lcn_connection): + """Test the setup of binary sensor.""" + for entity_id in ( + BINARY_SENSOR_LOCKREGULATOR1, + BINARY_SENSOR_SENSOR1, + BINARY_SENSOR_KEYLOCK, + ): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_entity_state(hass, lcn_connection): + """Test state of entity.""" + state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) + assert state + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state + + state = hass.states.get(BINARY_SENSOR_KEYLOCK) + assert state + + +async def test_entity_attributes(hass, entry, lcn_connection): + """Test the attributes of an entity.""" + entity_registry = er.async_get(hass) + + entity_setpoint1 = entity_registry.async_get(BINARY_SENSOR_LOCKREGULATOR1) + assert entity_setpoint1 + assert entity_setpoint1.unique_id == f"{entry.entry_id}-m000007-r1varsetpoint" + assert entity_setpoint1.original_name == "Sensor_LockRegulator1" + + entity_binsensor1 = entity_registry.async_get(BINARY_SENSOR_SENSOR1) + assert entity_binsensor1 + assert entity_binsensor1.unique_id == f"{entry.entry_id}-m000007-binsensor1" + assert entity_binsensor1.original_name == "Binary_Sensor1" + + entity_keylock = entity_registry.async_get(BINARY_SENSOR_KEYLOCK) + assert entity_keylock + assert entity_keylock.unique_id == f"{entry.entry_id}-m000007-a5" + assert entity_keylock.original_name == "Sensor_KeyLock" + + +async def test_pushed_lock_setpoint_status_change(hass, entry, lcn_connection): + """Test the lock setpoint sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + # push status lock setpoint + inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x8000)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) + assert state is not None + assert state.state == STATE_ON + + # push status unlock setpoint + inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x7FFF)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) + assert state is not None + assert state.state == STATE_OFF + + +async def test_pushed_binsensor_status_change(hass, entry, lcn_connection): + """Test the binary port sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + states = [False] * 8 + + # push status binary port "off" + inp = ModStatusBinSensors(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state is not None + assert state.state == STATE_OFF + + # push status binary port "on" + states[0] = True + inp = ModStatusBinSensors(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state is not None + assert state.state == STATE_ON + + +async def test_pushed_keylock_status_change(hass, entry, lcn_connection): + """Test the keylock sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + states = [[False] * 8 for i in range(4)] + + # push status keylock "off" + inp = ModStatusKeyLocks(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_KEYLOCK) + assert state is not None + assert state.state == STATE_OFF + + # push status keylock "on" + states[0][4] = True + inp = ModStatusKeyLocks(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_KEYLOCK) + assert state is not None + assert state.state == STATE_ON + + +async def test_unload_config_entry(hass, entry, lcn_connection): + """Test the binary sensor is removed when the config entry is unloaded.""" + await hass.config_entries.async_unload(entry.entry_id) + assert hass.states.get(BINARY_SENSOR_LOCKREGULATOR1).state == STATE_UNAVAILABLE + assert hass.states.get(BINARY_SENSOR_SENSOR1).state == STATE_UNAVAILABLE + assert hass.states.get(BINARY_SENSOR_KEYLOCK).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index 8c6b814e525..b7eab5f2ecc 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -22,12 +22,15 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockModuleConnection +COVER_OUTPUTS = "cover.cover_outputs" +COVER_RELAYS = "cover.cover_relays" + async def test_setup_lcn_cover(hass, entry, lcn_connection): """Test the setup of cover.""" for entity_id in ( - "cover.cover_outputs", - "cover.cover_relays", + COVER_OUTPUTS, + COVER_RELAYS, ): state = hass.states.get(entity_id) assert state is not None @@ -38,13 +41,13 @@ async def test_entity_attributes(hass, entry, lcn_connection): """Test the attributes of an entity.""" entity_registry = er.async_get(hass) - entity_outputs = entity_registry.async_get("cover.cover_outputs") + entity_outputs = entity_registry.async_get(COVER_OUTPUTS) assert entity_outputs assert entity_outputs.unique_id == f"{entry.entry_id}-m000007-outputs" assert entity_outputs.original_name == "Cover_Outputs" - entity_relays = entity_registry.async_get("cover.cover_relays") + entity_relays = entity_registry.async_get(COVER_RELAYS) assert entity_relays assert entity_relays.unique_id == f"{entry.entry_id}-m000007-motor1" @@ -54,7 +57,7 @@ async def test_entity_attributes(hass, entry, lcn_connection): @patch.object(MockModuleConnection, "control_motors_outputs") async def test_outputs_open(control_motors_outputs, hass, lcn_connection): """Test the outputs cover opens.""" - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_CLOSED # command failed @@ -63,7 +66,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -71,7 +74,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): MotorStateModifier.UP, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state != STATE_OPENING @@ -82,7 +85,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -90,7 +93,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): MotorStateModifier.UP, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_OPENING @@ -98,7 +101,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): @patch.object(MockModuleConnection, "control_motors_outputs") async def test_outputs_close(control_motors_outputs, hass, lcn_connection): """Test the outputs cover closes.""" - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_OPEN # command failed @@ -107,7 +110,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -115,7 +118,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state != STATE_CLOSING @@ -126,7 +129,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -134,7 +137,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_CLOSING @@ -142,7 +145,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): @patch.object(MockModuleConnection, "control_motors_outputs") async def test_outputs_stop(control_motors_outputs, hass, lcn_connection): """Test the outputs cover stops.""" - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_CLOSING # command failed @@ -151,13 +154,13 @@ async def test_outputs_stop(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_CLOSING @@ -168,13 +171,13 @@ async def test_outputs_stop(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state not in (STATE_CLOSING, STATE_OPENING) @@ -185,7 +188,7 @@ async def test_relays_open(control_motors_relays, hass, lcn_connection): states = [MotorStateModifier.NOCHANGE] * 4 states[0] = MotorStateModifier.UP - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_CLOSED # command failed @@ -194,13 +197,13 @@ async def test_relays_open(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != STATE_OPENING @@ -211,13 +214,13 @@ async def test_relays_open(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_OPENING @@ -228,7 +231,7 @@ async def test_relays_close(control_motors_relays, hass, lcn_connection): states = [MotorStateModifier.NOCHANGE] * 4 states[0] = MotorStateModifier.DOWN - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_OPEN # command failed @@ -237,13 +240,13 @@ async def test_relays_close(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != STATE_CLOSING @@ -254,13 +257,13 @@ async def test_relays_close(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_CLOSING @@ -271,7 +274,7 @@ async def test_relays_stop(control_motors_relays, hass, lcn_connection): states = [MotorStateModifier.NOCHANGE] * 4 states[0] = MotorStateModifier.STOP - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_CLOSING # command failed @@ -280,13 +283,13 @@ async def test_relays_stop(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_CLOSING @@ -297,13 +300,13 @@ async def test_relays_stop(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state not in (STATE_CLOSING, STATE_OPENING) @@ -313,33 +316,33 @@ async def test_pushed_outputs_status_change(hass, entry, lcn_connection): device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_CLOSED # push status "open" - input = ModStatusOutput(address, 0, 100) - await device_connection.async_process_input(input) + inp = ModStatusOutput(address, 0, 100) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_OPENING # push status "stop" - input = ModStatusOutput(address, 0, 0) - await device_connection.async_process_input(input) + inp = ModStatusOutput(address, 0, 0) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state not in (STATE_OPENING, STATE_CLOSING) # push status "close" - input = ModStatusOutput(address, 1, 100) - await device_connection.async_process_input(input) + inp = ModStatusOutput(address, 1, 100) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_CLOSING @@ -350,36 +353,36 @@ async def test_pushed_relays_status_change(hass, entry, lcn_connection): address = LcnAddr(0, 7, False) states = [False] * 8 - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_CLOSED # push status "open" states[0:2] = [True, False] - input = ModStatusRelays(address, states) - await device_connection.async_process_input(input) + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_OPENING # push status "stop" states[0] = False - input = ModStatusRelays(address, states) - await device_connection.async_process_input(input) + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state not in (STATE_OPENING, STATE_CLOSING) # push status "close" states[0:2] = [True, True] - input = ModStatusRelays(address, states) - await device_connection.async_process_input(input) + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_CLOSING @@ -387,5 +390,5 @@ async def test_pushed_relays_status_change(hass, entry, lcn_connection): async def test_unload_config_entry(hass, entry, lcn_connection): """Test the cover is removed when the config entry is unloaded.""" await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get("cover.cover_outputs").state == STATE_UNAVAILABLE - assert hass.states.get("cover.cover_relays").state == STATE_UNAVAILABLE + assert hass.states.get(COVER_OUTPUTS).state == STATE_UNAVAILABLE + assert hass.states.get(COVER_RELAYS).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index a52df987e5e..b908d21d5f5 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -29,7 +29,13 @@ async def test_get_triggers_module_device(hass, entry, lcn_connection): CONF_DEVICE_ID: device.id, "metadata": {}, } - for trigger in ["transmitter", "transponder", "fingerprint", "send_keys"] + for trigger in [ + "transmitter", + "transponder", + "fingerprint", + "codelock", + "send_keys", + ] ] triggers = await async_get_device_automations( @@ -147,6 +153,51 @@ async def test_if_fires_on_fingerprint_event(hass, calls, entry, lcn_connection) } +async def test_if_fires_on_codelock_event(hass, calls, entry, lcn_connection): + """Test for codelock event triggers firing.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "codelock", + }, + "action": { + "service": "test.automation", + "data_template": { + "test": "test_trigger_codelock", + "code": "{{ trigger.event.data.code }}", + }, + }, + }, + ] + }, + ) + + inp = ModStatusAccessControl( + LcnAddr(*address), + periphery=AccessControlPeriphery.CODELOCK, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + "test": "test_trigger_codelock", + "code": "aabbcc", + } + + async def test_if_fires_on_transmitter_event(hass, calls, entry, lcn_connection): """Test for transmitter event triggers firing.""" address = (0, 7, False) diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py index 38a685ad663..9786d1895da 100644 --- a/tests/components/lcn/test_events.py +++ b/tests/components/lcn/test_events.py @@ -5,10 +5,15 @@ from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand from tests.common import async_capture_events +LCN_TRANSPONDER = "lcn_transponder" +LCN_FINGERPRINT = "lcn_fingerprint" +LCN_TRANSMITTER = "lcn_transmitter" +LCN_SEND_KEYS = "lcn_send_keys" + async def test_fire_transponder_event(hass, lcn_connection): """Test the transponder event is fired.""" - events = async_capture_events(hass, "lcn_transponder") + events = async_capture_events(hass, LCN_TRANSPONDER) inp = ModStatusAccessControl( LcnAddr(0, 7, False), @@ -20,13 +25,13 @@ async def test_fire_transponder_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 1 - assert events[0].event_type == "lcn_transponder" + assert events[0].event_type == LCN_TRANSPONDER assert events[0].data["code"] == "aabbcc" async def test_fire_fingerprint_event(hass, lcn_connection): """Test the fingerprint event is fired.""" - events = async_capture_events(hass, "lcn_fingerprint") + events = async_capture_events(hass, LCN_FINGERPRINT) inp = ModStatusAccessControl( LcnAddr(0, 7, False), @@ -38,13 +43,31 @@ async def test_fire_fingerprint_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 1 - assert events[0].event_type == "lcn_fingerprint" + assert events[0].event_type == LCN_FINGERPRINT + assert events[0].data["code"] == "aabbcc" + + +async def test_fire_codelock_event(hass, lcn_connection): + """Test the codelock event is fired.""" + events = async_capture_events(hass, "lcn_codelock") + + inp = ModStatusAccessControl( + LcnAddr(0, 7, False), + periphery=AccessControlPeriphery.CODELOCK, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].event_type == "lcn_codelock" assert events[0].data["code"] == "aabbcc" async def test_fire_transmitter_event(hass, lcn_connection): """Test the transmitter event is fired.""" - events = async_capture_events(hass, "lcn_transmitter") + events = async_capture_events(hass, LCN_TRANSMITTER) inp = ModStatusAccessControl( LcnAddr(0, 7, False), @@ -59,7 +82,7 @@ async def test_fire_transmitter_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 1 - assert events[0].event_type == "lcn_transmitter" + assert events[0].event_type == LCN_TRANSMITTER assert events[0].data["code"] == "aabbcc" assert events[0].data["level"] == 0 assert events[0].data["key"] == 0 @@ -68,7 +91,7 @@ async def test_fire_transmitter_event(hass, lcn_connection): async def test_fire_sendkeys_event(hass, lcn_connection): """Test the send_keys event is fired.""" - events = async_capture_events(hass, "lcn_send_keys") + events = async_capture_events(hass, LCN_SEND_KEYS) inp = ModSendKeysHost( LcnAddr(0, 7, False), @@ -80,16 +103,16 @@ async def test_fire_sendkeys_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 4 - assert events[0].event_type == "lcn_send_keys" + assert events[0].event_type == LCN_SEND_KEYS assert events[0].data["key"] == "a1" assert events[0].data["action"] == "hit" - assert events[1].event_type == "lcn_send_keys" + assert events[1].event_type == LCN_SEND_KEYS assert events[1].data["key"] == "a2" assert events[1].data["action"] == "hit" - assert events[2].event_type == "lcn_send_keys" + assert events[2].event_type == LCN_SEND_KEYS assert events[2].data["key"] == "b1" assert events[2].data["action"] == "make" - assert events[3].event_type == "lcn_send_keys" + assert events[3].event_type == LCN_SEND_KEYS assert events[3].data["key"] == "b2" assert events[3].data["action"] == "make" @@ -99,10 +122,10 @@ async def test_dont_fire_on_non_module_input(hass, lcn_connection): inp = Input() for event_name in ( - "lcn_transponder", - "lcn_fingerprint", - "lcn_transmitter", - "lcn_send_keys", + LCN_TRANSPONDER, + LCN_FINGERPRINT, + LCN_TRANSMITTER, + LCN_SEND_KEYS, ): events = async_capture_events(hass, event_name) await lcn_connection.async_process_input(inp) @@ -118,7 +141,7 @@ async def test_dont_fire_on_unknown_module(hass, lcn_connection): code="aabbcc", ) - events = async_capture_events(hass, "lcn_fingerprint") + events = async_capture_events(hass, LCN_FINGERPRINT) await lcn_connection.async_process_input(inp) await hass.async_block_till_done() assert len(events) == 0 diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index efde0daa68f..1795f716868 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -27,13 +27,17 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockModuleConnection +LIGHT_OUTPUT1 = "light.light_output1" +LIGHT_OUTPUT2 = "light.light_output2" +LIGHT_RELAY1 = "light.light_relay1" + async def test_setup_lcn_light(hass, lcn_connection): """Test the setup of light.""" for entity_id in ( - "light.light_output1", - "light.light_output2", - "light.light_relay1", + LIGHT_OUTPUT1, + LIGHT_OUTPUT2, + LIGHT_RELAY1, ): state = hass.states.get(entity_id) assert state is not None @@ -42,12 +46,12 @@ async def test_setup_lcn_light(hass, lcn_connection): async def test_entity_state(hass, lcn_connection): """Test state of entity.""" - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - state = hass.states.get("light.light_output2") + state = hass.states.get(LIGHT_OUTPUT2) assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] @@ -57,13 +61,13 @@ async def test_entity_attributes(hass, entry, lcn_connection): """Test the attributes of an entity.""" entity_registry = er.async_get(hass) - entity_output = entity_registry.async_get("light.light_output1") + entity_output = entity_registry.async_get(LIGHT_OUTPUT1) assert entity_output assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" assert entity_output.original_name == "Light_Output1" - entity_relay = entity_registry.async_get("light.light_relay1") + entity_relay = entity_registry.async_get(LIGHT_RELAY1) assert entity_relay assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" @@ -79,13 +83,13 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_ON @@ -96,13 +100,13 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON @@ -116,7 +120,7 @@ async def test_output_turn_on_with_attributes(dim_output, hass, lcn_connection): DOMAIN_LIGHT, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.light_output1", + ATTR_ENTITY_ID: LIGHT_OUTPUT1, ATTR_BRIGHTNESS: 50, ATTR_TRANSITION: 2, }, @@ -125,7 +129,7 @@ async def test_output_turn_on_with_attributes(dim_output, hass, lcn_connection): await hass.async_block_till_done() dim_output.assert_awaited_with(0, 19, 6) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON @@ -133,7 +137,7 @@ async def test_output_turn_on_with_attributes(dim_output, hass, lcn_connection): @patch.object(MockModuleConnection, "dim_output") async def test_output_turn_off(dim_output, hass, lcn_connection): """Test the output light turns off.""" - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) state.state = STATE_ON # command failed @@ -142,13 +146,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_OFF @@ -159,13 +163,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_OFF @@ -175,14 +179,14 @@ async def test_output_turn_off_with_attributes(dim_output, hass, lcn_connection) """Test the output light turns off.""" dim_output.return_value = True - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) state.state = STATE_ON await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: "light.light_output1", + ATTR_ENTITY_ID: LIGHT_OUTPUT1, ATTR_TRANSITION: 2, }, blocking=True, @@ -190,7 +194,7 @@ async def test_output_turn_off_with_attributes(dim_output, hass, lcn_connection) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 6) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_OFF @@ -207,13 +211,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state != STATE_ON @@ -224,13 +228,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_ON @@ -241,7 +245,7 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) state.state = STATE_ON # command failed @@ -250,13 +254,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state != STATE_OFF @@ -267,13 +271,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_OFF @@ -288,7 +292,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 127 @@ -298,7 +302,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_OFF @@ -315,7 +319,7 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_ON @@ -325,7 +329,7 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_OFF @@ -333,4 +337,4 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): async def test_unload_config_entry(hass, entry, lcn_connection): """Test the light is removed when the config entry is unloaded.""" await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get("light.light_output1").state == STATE_UNAVAILABLE + assert hass.states.get(LIGHT_OUTPUT1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py new file mode 100644 index 00000000000..4b6c0beb7e2 --- /dev/null +++ b/tests/components/lcn/test_sensor.py @@ -0,0 +1,131 @@ +"""Test for the LCN sensor platform.""" +from pypck.inputs import ModStatusLedsAndLogicOps, ModStatusVar +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import LedStatus, LogicOpStatus, Var, VarValue + +from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + TEMP_CELSIUS, +) +from homeassistant.helpers import entity_registry as er + +SENSOR_VAR1 = "sensor.sensor_var1" +SENSOR_SETPOINT1 = "sensor.sensor_setpoint1" +SENSOR_LED6 = "sensor.sensor_led6" +SENSOR_LOGICOP1 = "sensor.sensor_logicop1" + + +async def test_setup_lcn_sensor(hass, entry, lcn_connection): + """Test the setup of sensor.""" + for entity_id in ( + SENSOR_VAR1, + SENSOR_SETPOINT1, + SENSOR_LED6, + SENSOR_LOGICOP1, + ): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_entity_state(hass, lcn_connection): + """Test state of entity.""" + state = hass.states.get(SENSOR_VAR1) + assert state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + + state = hass.states.get(SENSOR_SETPOINT1) + assert state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + + state = hass.states.get(SENSOR_LED6) + assert state + + state = hass.states.get(SENSOR_LOGICOP1) + assert state + + +async def test_entity_attributes(hass, entry, lcn_connection): + """Test the attributes of an entity.""" + entity_registry = er.async_get(hass) + + entity_var1 = entity_registry.async_get(SENSOR_VAR1) + assert entity_var1 + assert entity_var1.unique_id == f"{entry.entry_id}-m000007-var1" + assert entity_var1.original_name == "Sensor_Var1" + + entity_r1varsetpoint = entity_registry.async_get(SENSOR_SETPOINT1) + assert entity_r1varsetpoint + assert entity_r1varsetpoint.unique_id == f"{entry.entry_id}-m000007-r1varsetpoint" + assert entity_r1varsetpoint.original_name == "Sensor_Setpoint1" + + entity_led6 = entity_registry.async_get(SENSOR_LED6) + assert entity_led6 + assert entity_led6.unique_id == f"{entry.entry_id}-m000007-led6" + assert entity_led6.original_name == "Sensor_Led6" + + entity_logicop1 = entity_registry.async_get(SENSOR_LOGICOP1) + assert entity_logicop1 + assert entity_logicop1.unique_id == f"{entry.entry_id}-m000007-logicop1" + assert entity_logicop1.original_name == "Sensor_LogicOp1" + + +async def test_pushed_variable_status_change(hass, entry, lcn_connection): + """Test the variable sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + # push status variable + inp = ModStatusVar(address, Var.VAR1, VarValue.from_celsius(42)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(SENSOR_VAR1) + assert state is not None + assert float(state.state) == 42.0 + + # push status setpoint + inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue.from_celsius(42)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(SENSOR_SETPOINT1) + assert state is not None + assert float(state.state) == 42.0 + + +async def test_pushed_ledlogicop_status_change(hass, entry, lcn_connection): + """Test the led and logicop sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + states_led = [LedStatus.OFF] * 12 + states_logicop = [LogicOpStatus.NONE] * 4 + + states_led[5] = LedStatus.ON + states_logicop[0] = LogicOpStatus.ALL + + # push status led and logicop + inp = ModStatusLedsAndLogicOps(address, states_led, states_logicop) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(SENSOR_LED6) + assert state is not None + assert state.state == "on" + + state = hass.states.get(SENSOR_LOGICOP1) + assert state is not None + assert state.state == "all" + + +async def test_unload_config_entry(hass, entry, lcn_connection): + """Test the sensor is removed when the config entry is unloaded.""" + await hass.config_entries.async_unload(entry.entry_id) + assert hass.states.get(SENSOR_VAR1).state == STATE_UNAVAILABLE + assert hass.states.get(SENSOR_SETPOINT1).state == STATE_UNAVAILABLE + assert hass.states.get(SENSOR_LED6).state == STATE_UNAVAILABLE + assert hass.states.get(SENSOR_LOGICOP1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 8c4fb1ff0a8..a21bd35db09 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -19,14 +19,19 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockModuleConnection +SWITCH_OUTPUT1 = "switch.switch_output1" +SWITCH_OUTPUT2 = "switch.switch_output2" +SWITCH_RELAY1 = "switch.switch_relay1" +SWITCH_RELAY2 = "switch.switch_relay2" + async def test_setup_lcn_switch(hass, lcn_connection): """Test the setup of switch.""" for entity_id in ( - "switch.switch_output1", - "switch.switch_output2", - "switch.switch_relay1", - "switch.switch_relay2", + SWITCH_OUTPUT1, + SWITCH_OUTPUT2, + SWITCH_RELAY1, + SWITCH_RELAY2, ): state = hass.states.get(entity_id) assert state is not None @@ -37,13 +42,13 @@ async def test_entity_attributes(hass, entry, lcn_connection): """Test the attributes of an entity.""" entity_registry = er.async_get(hass) - entity_output = entity_registry.async_get("switch.switch_output1") + entity_output = entity_registry.async_get(SWITCH_OUTPUT1) assert entity_output assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" assert entity_output.original_name == "Switch_Output1" - entity_relay = entity_registry.async_get("switch.switch_relay1") + entity_relay = entity_registry.async_get(SWITCH_RELAY1) assert entity_relay assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" @@ -59,13 +64,13 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_OFF # command success @@ -75,20 +80,20 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_ON @patch.object(MockModuleConnection, "dim_output") async def test_output_turn_off(dim_output, hass, lcn_connection): """Test the output switch turns off.""" - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) state.state = STATE_ON # command failed @@ -97,13 +102,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_ON # command success @@ -113,13 +118,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_OFF @@ -135,13 +140,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_OFF # command success @@ -151,13 +156,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_ON @@ -167,7 +172,7 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) state.state = STATE_ON # command failed @@ -176,13 +181,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_ON # command success @@ -192,13 +197,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_OFF @@ -212,7 +217,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_ON # push status "off" @@ -220,7 +225,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_OFF @@ -236,7 +241,7 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_ON # push status "off" @@ -245,11 +250,11 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_OFF async def test_unload_config_entry(hass, entry, lcn_connection): """Test the switch is removed when the config entry is unloaded.""" await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get("switch.switch_output1").state == STATE_UNAVAILABLE + assert hass.states.get(SWITCH_OUTPUT1).state == STATE_UNAVAILABLE diff --git a/tests/components/lg_soundbar/__init__.py b/tests/components/lg_soundbar/__init__.py new file mode 100644 index 00000000000..8756d343130 --- /dev/null +++ b/tests/components/lg_soundbar/__init__.py @@ -0,0 +1 @@ +"""Tests for the lg_soundbar component.""" diff --git a/tests/components/lg_soundbar/test_config_flow.py b/tests/components/lg_soundbar/test_config_flow.py new file mode 100644 index 00000000000..3fafc2c7628 --- /dev/null +++ b/tests/components/lg_soundbar/test_config_flow.py @@ -0,0 +1,95 @@ +"""Test the lg_soundbar config flow.""" +from unittest.mock import MagicMock, patch + +from homeassistant import config_entries +from homeassistant.components.lg_soundbar.const import DEFAULT_PORT, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.lg_soundbar.config_flow.temescal", + return_value=MagicMock(), + ), patch( + "homeassistant.components.lg_soundbar.config_flow.test_connect", + return_value={"uuid": "uuid", "name": "name"}, + ), patch( + "homeassistant.components.lg_soundbar.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "name" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.lg_soundbar.config_flow.test_connect", + side_effect=ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_already_configured(hass): + """Test we handle already configured error.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 0000, + }, + unique_id="uuid", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.lg_soundbar.config_flow.test_connect", + return_value={"uuid": "uuid", "name": "name"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" diff --git a/tests/components/life360/__init__.py b/tests/components/life360/__init__.py new file mode 100644 index 00000000000..0f68b4a343c --- /dev/null +++ b/tests/components/life360/__init__.py @@ -0,0 +1 @@ +"""Tests for the Life360 integration.""" diff --git a/tests/components/life360/test_config_flow.py b/tests/components/life360/test_config_flow.py new file mode 100644 index 00000000000..0b5b850ac23 --- /dev/null +++ b/tests/components/life360/test_config_flow.py @@ -0,0 +1,309 @@ +"""Test the Life360 config flow.""" + +from unittest.mock import patch + +from life360 import Life360Error, LoginError +import pytest +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.life360.const import ( + CONF_AUTHORIZATION, + CONF_DRIVING_SPEED, + CONF_MAX_GPS_ACCURACY, + DEFAULT_OPTIONS, + DOMAIN, + SHOW_DRIVING, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +TEST_USER = "Test@Test.com" +TEST_PW = "password" +TEST_PW_3 = "password_3" +TEST_AUTHORIZATION = "authorization_string" +TEST_AUTHORIZATION_2 = "authorization_string_2" +TEST_AUTHORIZATION_3 = "authorization_string_3" +TEST_MAX_GPS_ACCURACY = "300" +TEST_DRIVING_SPEED = "18" +TEST_SHOW_DRIVING = True + +USER_INPUT = {CONF_USERNAME: TEST_USER, CONF_PASSWORD: TEST_PW} + +TEST_CONFIG_DATA = { + CONF_USERNAME: TEST_USER, + CONF_PASSWORD: TEST_PW, + CONF_AUTHORIZATION: TEST_AUTHORIZATION, +} +TEST_CONFIG_DATA_2 = { + CONF_USERNAME: TEST_USER, + CONF_PASSWORD: TEST_PW, + CONF_AUTHORIZATION: TEST_AUTHORIZATION_2, +} +TEST_CONFIG_DATA_3 = { + CONF_USERNAME: TEST_USER, + CONF_PASSWORD: TEST_PW_3, + CONF_AUTHORIZATION: TEST_AUTHORIZATION_3, +} + +USER_OPTIONS = { + "limit_gps_acc": True, + CONF_MAX_GPS_ACCURACY: TEST_MAX_GPS_ACCURACY, + "set_drive_speed": True, + CONF_DRIVING_SPEED: TEST_DRIVING_SPEED, + SHOW_DRIVING: TEST_SHOW_DRIVING, +} +TEST_OPTIONS = { + CONF_MAX_GPS_ACCURACY: float(TEST_MAX_GPS_ACCURACY), + CONF_DRIVING_SPEED: float(TEST_DRIVING_SPEED), + SHOW_DRIVING: TEST_SHOW_DRIVING, +} + + +# ========== Common Fixtures & Functions =============================================== + + +@pytest.fixture(name="life360", autouse=True) +def life360_fixture(): + """Mock life360 config entry setup & unload.""" + with patch( + "homeassistant.components.life360.async_setup_entry", return_value=True + ), patch("homeassistant.components.life360.async_unload_entry", return_value=True): + yield + + +@pytest.fixture +def life360_api(): + """Mock Life360 api.""" + with patch("homeassistant.components.life360.config_flow.Life360") as mock: + yield mock.return_value + + +def create_config_entry(hass, state=None): + """Create mock config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG_DATA, + version=1, + state=state, + options=DEFAULT_OPTIONS, + unique_id=TEST_USER.lower(), + ) + config_entry.add_to_hass(hass) + return config_entry + + +# ========== User Flow Tests =========================================================== + + +async def test_user_show_form(hass, life360_api): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_not_called() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + schema = result["data_schema"].schema + assert set(schema) == set(USER_INPUT) + # username and password fields should be empty. + keys = list(schema) + for key in USER_INPUT: + assert keys[keys.index(key)].default == vol.UNDEFINED + + +async def test_user_config_flow_success(hass, life360_api): + """Test a successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.return_value = TEST_AUTHORIZATION + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_USER.lower() + assert result["data"] == TEST_CONFIG_DATA + assert result["options"] == DEFAULT_OPTIONS + + +@pytest.mark.parametrize( + "exception,error", [(LoginError, "invalid_auth"), (Life360Error, "cannot_connect")] +) +async def test_user_config_flow_error(hass, life360_api, caplog, exception, error): + """Test a user config flow with an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.side_effect = exception("test reason") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] + assert result["errors"]["base"] == error + + assert "test reason" in caplog.text + + schema = result["data_schema"].schema + assert set(schema) == set(USER_INPUT) + # username and password fields should be prefilled with current values. + keys = list(schema) + for key, val in USER_INPUT.items(): + default = keys[keys.index(key)].default + assert default != vol.UNDEFINED + assert default() == val + + +async def test_user_config_flow_already_configured(hass, life360_api): + """Test a user config flow with an account already configured.""" + create_config_entry(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_not_called() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +# ========== Reauth Flow Tests ========================================================= + + +@pytest.mark.parametrize("state", [None, config_entries.ConfigEntryState.LOADED]) +async def test_reauth_config_flow_success(hass, life360_api, caplog, state): + """Test a successful reauthorization config flow.""" + config_entry = create_config_entry(hass, state=state) + + # Simulate current username & password are still valid, but authorization string has + # expired, such that getting a new authorization string from server is successful. + life360_api.get_authorization.return_value = TEST_AUTHORIZATION_2 + + result = await hass.config_entries.flow.async_init( + 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=config_entry.data, + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert "Reauthorization successful" in caplog.text + + assert config_entry.data == TEST_CONFIG_DATA_2 + + +async def test_reauth_config_flow_login_error(hass, life360_api, caplog): + """Test a reauthorization config flow with a login error.""" + config_entry = create_config_entry(hass) + + # Simulate current username & password are invalid, which results in a form + # requesting new password, with old password as default value. + life360_api.get_authorization.side_effect = LoginError("test reason") + + result = await hass.config_entries.flow.async_init( + 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=config_entry.data, + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] + assert result["errors"]["base"] == "invalid_auth" + + assert "test reason" in caplog.text + + schema = result["data_schema"].schema + assert len(schema) == 1 + assert "password" in schema + key = list(schema)[0] + assert key.default() == TEST_PW + + # Simulate getting a new, valid password. + life360_api.get_authorization.reset_mock(side_effect=True) + life360_api.get_authorization.return_value = TEST_AUTHORIZATION_3 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: TEST_PW_3} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert "Reauthorization successful" in caplog.text + + assert config_entry.data == TEST_CONFIG_DATA_3 + + +# ========== Option flow Tests ========================================================= + + +async def test_options_flow(hass): + """Test an options flow.""" + config_entry = create_config_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert not result["errors"] + + schema = result["data_schema"].schema + assert set(schema) == set(USER_OPTIONS) + + flow_id = result["flow_id"] + + result = await hass.config_entries.options.async_configure(flow_id, USER_OPTIONS) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == TEST_OPTIONS + + assert config_entry.options == TEST_OPTIONS diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index 7e004891bb8..b6d26306317 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ( ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index e8ec5324ae6..0e3d85dc828 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -1,7 +1,6 @@ """Configure pytest for Litter-Robot tests.""" from __future__ import annotations -from datetime import datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +9,6 @@ from pylitterbot.exceptions import InvalidCommandException import pytest from homeassistant.components import litterrobot -from homeassistant.components.litterrobot.vacuum import UNAVAILABLE_AFTER from homeassistant.core import HomeAssistant from .common import CONFIG, ROBOT_DATA @@ -73,14 +71,6 @@ def mock_account_with_sleep_disabled_robot() -> MagicMock: return create_mock_account({"sleepModeActive": "0"}) -@pytest.fixture -def mock_account_with_robot_not_recently_seen() -> MagicMock: - """Mock a Litter-Robot account with a sleeping robot.""" - return create_mock_account( - {"lastSeen": (datetime.now() - UNAVAILABLE_AFTER).isoformat()} - ) - - @pytest.fixture def mock_account_with_error() -> MagicMock: """Mock a Litter-Robot account with error.""" diff --git a/tests/components/litterrobot/test_button.py b/tests/components/litterrobot/test_button.py index 3f802d0e6b2..6291558c832 100644 --- a/tests/components/litterrobot/test_button.py +++ b/tests/components/litterrobot/test_button.py @@ -14,7 +14,6 @@ from .conftest import setup_integration BUTTON_ENTITY = "button.test_reset_waste_drawer" -@freeze_time("2021-11-15 17:37:00", tz_offset=-7) async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: """Test the creation and values of the Litter-Robot button.""" await setup_integration(hass, mock_account, BUTTON_DOMAIN) @@ -29,12 +28,13 @@ async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: assert entry assert entry.entity_category is EntityCategory.CONFIG - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: BUTTON_ENTITY}, - blocking=True, - ) + with freeze_time("2021-11-15 17:37:00", tz_offset=-7): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: BUTTON_ENTITY}, + blocking=True, + ) await hass.async_block_till_done() assert mock_account.robots[0].reset_waste_drawer.call_count == 1 mock_account.robots[0].reset_waste_drawer.assert_called_with() diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 3adf820d6aa..89f8f077b55 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -24,7 +24,7 @@ from homeassistant.components.vacuum import ( STATE_DOCKED, STATE_ERROR, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -62,19 +62,6 @@ async def test_vacuum_status_when_sleeping( assert vacuum.attributes.get(ATTR_STATUS) == "Ready (Sleeping)" -async def test_vacuum_state_when_not_recently_seen( - hass: HomeAssistant, mock_account_with_robot_not_recently_seen: MagicMock -) -> None: - """Tests the vacuum state when not seen recently.""" - await setup_integration( - hass, mock_account_with_robot_not_recently_seen, PLATFORM_DOMAIN - ) - - vacuum = hass.states.get(VACUUM_ENTITY_ID) - assert vacuum - assert vacuum.state == STATE_UNAVAILABLE - - async def test_no_robots( hass: HomeAssistant, mock_account_with_no_robots: MagicMock ) -> None: diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d16b3476d84..31ca1610250 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -2153,7 +2153,7 @@ async def test_include_exclude_events_with_glob_filters( client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 6 + assert len(entries) == 7 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) @@ -2162,6 +2162,7 @@ async def test_include_exclude_events_with_glob_filters( _assert_entry(entries[3], name="bla", entity_id=entity_id, state="20") _assert_entry(entries[4], name="blu", entity_id=entity_id2, state="20") _assert_entry(entries[5], name="included", entity_id=entity_id4, state="30") + _assert_entry(entries[6], name="included", entity_id=entity_id5, state="30") async def test_empty_config(hass, hass_client, recorder_mock): diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index ac6a31202e7..6c4908a2ad5 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -94,7 +94,7 @@ async def _async_mock_entity_with_logbook_platform(hass): return entry -async def _async_mock_device_with_logbook_platform(hass): +async def _async_mock_devices_with_logbook_platform(hass): """Mock an integration that provides a device that are described by the logbook.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) @@ -109,8 +109,18 @@ async def _async_mock_device_with_logbook_platform(hass): model="model", suggested_area="Game Room", ) + device2 = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:CC")}, + identifiers={("bridgeid", "4567")}, + sw_version="sw-version", + name="device name", + manufacturer="manufacturer", + model="model", + suggested_area="Living Room", + ) await _async_mock_logbook_platform(hass) - return device + return [device, device2] async def test_get_events(hass, hass_ws_client, recorder_mock): @@ -392,10 +402,13 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): ] ) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] + device2 = devices[1] hass.bus.async_fire(EVENT_HOMEASSISTANT_START) hass.bus.async_fire("mock_event", {"device_id": device.id}) + hass.bus.async_fire("mock_event", {"device_id": device2.id}) hass.states.async_set("light.kitchen", STATE_OFF) await hass.async_block_till_done() @@ -423,7 +436,7 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): "id": 1, "type": "logbook/get_events", "start_time": now.isoformat(), - "device_ids": [device.id], + "device_ids": [device.id, device2.id], } ) response = await client.receive_json() @@ -431,10 +444,13 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): assert response["id"] == 1 results = response["result"] - assert len(results) == 1 + assert len(results) == 2 assert results[0]["name"] == "device name" assert results[0]["message"] == "is on fire" assert isinstance(results[0]["when"], float) + assert results[1]["name"] == "device name" + assert results[1]["message"] == "is on fire" + assert isinstance(results[1]["when"], float) await client.send_json( { @@ -470,17 +486,20 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): assert response["id"] == 3 results = response["result"] - assert len(results) == 4 + assert len(results) == 5 assert results[0]["message"] == "started" assert results[1]["name"] == "device name" assert results[1]["message"] == "is on fire" assert isinstance(results[1]["when"], float) - assert results[2]["entity_id"] == "light.kitchen" - assert results[2]["state"] == "on" + assert results[2]["name"] == "device name" + assert results[2]["message"] == "is on fire" assert isinstance(results[2]["when"], float) assert results[3]["entity_id"] == "light.kitchen" - assert results[3]["state"] == "off" + assert results[3]["state"] == "on" assert isinstance(results[3]["when"], float) + assert results[4]["entity_id"] == "light.kitchen" + assert results[4]["state"] == "off" + assert isinstance(results[4]["when"], float) @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) @@ -1731,7 +1750,9 @@ async def test_subscribe_unsubscribe_logbook_stream_device( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] + device2 = devices[1] await hass.async_block_till_done() init_count = sum(hass.bus.async_listeners().values()) @@ -1743,7 +1764,7 @@ async def test_subscribe_unsubscribe_logbook_stream_device( "id": 7, "type": "logbook/event_stream", "start_time": now.isoformat(), - "device_ids": [device.id], + "device_ids": [device.id, device2.id], } ) @@ -1775,6 +1796,29 @@ async def test_subscribe_unsubscribe_logbook_stream_device( {"domain": "test", "message": "is on fire", "name": "device name", "when": ANY} ] + for _ in range(3): + hass.bus.async_fire("mock_event", {"device_id": device.id}) + hass.bus.async_fire("mock_event", {"device_id": device2.id}) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + ] + await websocket_client.send_json( {"id": 8, "type": "unsubscribe_events", "subscription": 7} ) @@ -1950,7 +1994,8 @@ async def test_live_stream_with_one_second_commit_interval( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] await hass.async_block_till_done() init_count = sum(hass.bus.async_listeners().values()) @@ -2143,7 +2188,8 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo ] ) await async_wait_recording_done(hass) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] await async_wait_recording_done(hass) # Block the recorder queue @@ -2518,3 +2564,96 @@ async def test_logbook_stream_ignores_forced_updates( # Check our listener got unsubscribed assert sum(hass.bus.async_listeners().values()) == init_count + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_all_entities_are_continuous_with_device( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream with entities that are always filtered and a device.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + await async_wait_recording_done(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] + device2 = devices[1] + + entity_ids = ("sensor.uom", "sensor.uom_two") + + def _create_events(): + for entity_id in entity_ids: + for state in ("1", "2", "3"): + hass.states.async_set( + entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} + ) + hass.states.async_set("counter.any", state) + hass.states.async_set("proximity.any", state) + hass.bus.async_fire("mock_event", {"device_id": device.id}) + hass.bus.async_fire("mock_event", {"device_id": device2.id}) + + init_count = sum(hass.bus.async_listeners().values()) + _create_events() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": ["sensor.uom", "counter.any", "proximity.any"], + "device_ids": [device.id, device2.id], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"domain": "test", "message": "is on fire", "name": "device name", "when": ANY}, + {"domain": "test", "message": "is on fire", "name": "device name", "when": ANY}, + ] + assert msg["event"]["partial"] is True + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" not in msg["event"] + + for _ in range(2): + _create_events() + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + ] + assert "partial" not in msg["event"] + + await websocket_client.close() + await hass.async_block_till_done() + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index cb38df6a381..bdf1e359673 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -1,4 +1,6 @@ """The tests for Lutron Caséta device triggers.""" +from unittest.mock import MagicMock + import pytest from homeassistant.components import automation @@ -15,12 +17,12 @@ from homeassistant.components.lutron_caseta import ( ATTR_TYPE, ) from homeassistant.components.lutron_caseta.const import ( - BUTTON_DEVICES, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, ) from homeassistant.components.lutron_caseta.device_trigger import CONF_SUBTYPE +from homeassistant.components.lutron_caseta.models import LutronCasetaData from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -83,15 +85,17 @@ async def _async_setup_lutron_with_picos(hass, device_reg): ) dr_button_devices[dr_device.id] = device - hass.data[DOMAIN][config_entry.entry_id] = {BUTTON_DEVICES: dr_button_devices} - + hass.data[DOMAIN][config_entry.entry_id] = LutronCasetaData( + MagicMock(), MagicMock(), dr_button_devices + ) return config_entry.entry_id async def test_get_triggers(hass, device_reg): """Test we get the expected triggers from a lutron pico.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] expected_triggers = [ @@ -142,7 +146,8 @@ async def test_if_fires_on_button_event(hass, calls, device_reg): """Test for press trigger firing.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] device = dr_button_devices[device_id] assert await async_setup_component( @@ -224,7 +229,8 @@ async def test_validate_trigger_config_unknown_device(hass, calls, device_reg): """Test for no press with an unknown device.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] device = dr_button_devices[device_id] device["type"] = "unknown" @@ -267,7 +273,8 @@ async def test_validate_trigger_config_unknown_device(hass, calls, device_reg): async def test_validate_trigger_invalid_triggers(hass, device_reg): """Test for click_event with invalid triggers.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] assert await async_setup_component( hass, diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 9d221bbfe88..bd443bb17f3 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -203,12 +203,6 @@ async def test_device_no_nickname(hass): @pytest.mark.parametrize( "service, service_data, expected_args", [ - ("start_charging", {}, [12345]), - ("start_engine", {}, [12345]), - ("stop_charging", {}, [12345]), - ("stop_engine", {}, [12345]), - ("turn_off_hazard_lights", {}, [12345]), - ("turn_on_hazard_lights", {}, [12345]), ( "send_poi", {"latitude": 1.2345, "longitude": 2.3456, "poi_name": "Work"}, @@ -241,7 +235,15 @@ async def test_service_invalid_device_id(hass): with pytest.raises(vol.error.MultipleInvalid) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": "invalid"}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": "invalid", + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() @@ -262,7 +264,15 @@ async def test_service_device_id_not_mazda_vehicle(hass): with pytest.raises(vol.error.MultipleInvalid) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": other_device.id}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": other_device.id, + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() @@ -287,7 +297,15 @@ async def test_service_vehicle_id_not_found(hass): with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": device_id}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": device_id, + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() @@ -324,11 +342,19 @@ async def test_service_mazda_api_error(hass): device_id = reg_device.id with patch( - "homeassistant.components.mazda.MazdaAPI.start_engine", + "homeassistant.components.mazda.MazdaAPI.send_poi", side_effect=MazdaException("Test error"), ), pytest.raises(HomeAssistantError) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": device_id}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": device_id, + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 53c80bfc8de..8be263e7ee0 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -8,6 +8,7 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, + STATE_STANDBY, ) @@ -79,9 +80,13 @@ class ExtendedMediaPlayer(mp.MediaPlayerEntity): """Turn off state.""" self._state = STATE_OFF + def standby(self): + """Put device in standby.""" + self._state = STATE_STANDBY + def toggle(self): """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE]: + if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: self._state = STATE_ON else: self._state = STATE_OFF @@ -138,6 +143,10 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): """Turn off state.""" self._state = STATE_OFF + def standby(self): + """Put device in standby.""" + self._state = STATE_STANDBY + @pytest.fixture(params=[ExtendedMediaPlayer, SimpleMediaPlayer]) def player(hass, request): @@ -188,3 +197,7 @@ async def test_toggle(player): assert player.state == STATE_ON await player.async_toggle() assert player.state == STATE_OFF + player.standby() + assert player.state == STATE_STANDBY + await player.async_toggle() + assert player.state == STATE_ON diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index 7f6b15768f2..1d053a23cee 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_SOUND_MODE_LIST, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index bf279ff3cf7..a93b1ea6b62 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -133,9 +133,9 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 17 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Forecasts added - just pick out 1 entry to check @@ -148,7 +148,7 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.attributes.get("forecast")[26]["condition"] == "cloudy" assert weather.attributes.get("forecast")[26]["precipitation_probability"] == 9 assert weather.attributes.get("forecast")[26]["temperature"] == 10 - assert weather.attributes.get("forecast")[26]["wind_speed"] == 4 + assert weather.attributes.get("forecast")[26]["wind_speed"] == 6.44 assert weather.attributes.get("forecast")[26]["wind_bearing"] == "NNE" # Wavertree daily weather platform expected results @@ -157,9 +157,8 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 19 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check @@ -172,7 +171,7 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.attributes.get("forecast")[3]["condition"] == "rainy" assert weather.attributes.get("forecast")[3]["precipitation_probability"] == 59 assert weather.attributes.get("forecast")[3]["temperature"] == 13 - assert weather.attributes.get("forecast")[3]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[3]["wind_speed"] == 20.92 assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" @@ -229,9 +228,9 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 17 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Forecasts added - just pick out 1 entry to check @@ -244,7 +243,7 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[18]["condition"] == "clear-night" assert weather.attributes.get("forecast")[18]["precipitation_probability"] == 1 assert weather.attributes.get("forecast")[18]["temperature"] == 9 - assert weather.attributes.get("forecast")[18]["wind_speed"] == 4 + assert weather.attributes.get("forecast")[18]["wind_speed"] == 6.44 assert weather.attributes.get("forecast")[18]["wind_bearing"] == "NW" # Wavertree daily weather platform expected results @@ -253,9 +252,9 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 19 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check @@ -268,7 +267,7 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[3]["condition"] == "rainy" assert weather.attributes.get("forecast")[3]["precipitation_probability"] == 59 assert weather.attributes.get("forecast")[3]["temperature"] == 13 - assert weather.attributes.get("forecast")[3]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[3]["wind_speed"] == 20.92 assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" # King's Lynn 3-hourly weather platform expected results @@ -277,9 +276,9 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 14 - assert weather.attributes.get("wind_speed") == 2 + assert weather.attributes.get("wind_speed") == 3.22 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "E" - assert weather.attributes.get("visibility") == "Very Good - 20-40" assert weather.attributes.get("humidity") == 60 # Also has Forecast added - just pick out 1 entry to check @@ -292,7 +291,7 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[18]["condition"] == "cloudy" assert weather.attributes.get("forecast")[18]["precipitation_probability"] == 9 assert weather.attributes.get("forecast")[18]["temperature"] == 10 - assert weather.attributes.get("forecast")[18]["wind_speed"] == 7 + assert weather.attributes.get("forecast")[18]["wind_speed"] == 11.27 assert weather.attributes.get("forecast")[18]["wind_bearing"] == "SE" # King's Lynn daily weather platform expected results @@ -301,9 +300,9 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "cloudy" assert weather.attributes.get("temperature") == 9 - assert weather.attributes.get("wind_speed") == 4 + assert weather.attributes.get("wind_speed") == 6.44 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "ESE" - assert weather.attributes.get("visibility") == "Very Good - 20-40" assert weather.attributes.get("humidity") == 75 # All should have Forecast added - again, just picking out 1 entry to check @@ -316,5 +315,5 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[2]["condition"] == "cloudy" assert weather.attributes.get("forecast")[2]["precipitation_probability"] == 14 assert weather.attributes.get("forecast")[2]["temperature"] == 11 - assert weather.attributes.get("forecast")[2]["wind_speed"] == 7 + assert weather.attributes.get("forecast")[2]["wind_speed"] == 11.27 assert weather.attributes.get("forecast")[2]["wind_bearing"] == "ESE" diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index ae8013eff4b..6f67eea1a0a 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -1,19 +1,32 @@ """Tests for the Mikrotik component.""" -from homeassistant.components import mikrotik +from homeassistant.components.mikrotik.const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_DETECTION_TIME, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) MOCK_DATA = { - mikrotik.CONF_NAME: "Mikrotik", - mikrotik.CONF_HOST: "0.0.0.0", - mikrotik.CONF_USERNAME: "user", - mikrotik.CONF_PASSWORD: "pass", - mikrotik.CONF_PORT: 8278, - mikrotik.CONF_VERIFY_SSL: False, + CONF_NAME: "Mikrotik", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, } MOCK_OPTIONS = { - mikrotik.CONF_ARP_PING: False, - mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.CONF_DETECTION_TIME: mikrotik.DEFAULT_DETECTION_TIME, + CONF_ARP_PING: False, + CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: DEFAULT_DETECTION_TIME, } DEVICE_1_DHCP = { @@ -32,6 +45,14 @@ DEVICE_2_DHCP = { "host-name": "Device_2", "comment": "PC", } +DEVICE_3_DHCP_NUMERIC_NAME = { + ".id": "*1C", + "address": "0.0.0.3", + "mac-address": "00:00:00:00:00:03", + "active-address": "0.0.0.3", + "host-name": 123, + "comment": "Mobile", +} DEVICE_1_WIRELESS = { ".id": "*264", "interface": "wlan1", @@ -68,38 +89,16 @@ DEVICE_1_WIRELESS = { } DEVICE_2_WIRELESS = { + **DEVICE_1_WIRELESS, ".id": "*265", - "interface": "wlan1", "mac-address": "00:00:00:00:00:02", - "ap": False, - "wds": False, - "bridge": False, - "rx-rate": "72.2Mbps-20MHz/1S/SGI", - "tx-rate": "72.2Mbps-20MHz/1S/SGI", - "packets": "59542,17464", - "bytes": "17536671,2966351", - "frames": "59542,17472", - "frame-bytes": "17655785,2862445", - "hw-frames": "78935,38395", - "hw-frame-bytes": "25636019,4063445", - "tx-frames-timed-out": 0, - "uptime": "5h49m36s", - "last-activity": "170ms", - "signal-strength": "-62@1Mbps", - "signal-to-noise": 52, - "signal-strength-ch0": -63, - "signal-strength-ch1": -69, - "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", - "tx-ccq": 93, - "p-throughput": 54928, "last-ip": "0.0.0.2", - "802.1x-port-enabled": True, - "authentication-type": "wpa2-psk", - "encryption": "aes-ccm", - "group-encryption": "aes-ccm", - "management-protection": False, - "wmm-enabled": True, - "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} +DEVICE_3_WIRELESS = { + **DEVICE_1_WIRELESS, + ".id": "*266", + "mac-address": "00:00:00:00:00:03", + "last-ip": "0.0.0.3", } DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 411408e8c98..b4c087a436d 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -6,7 +6,12 @@ import librouteros import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components import mikrotik +from homeassistant.components.mikrotik.const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -34,9 +39,9 @@ DEMO_CONFIG = { CONF_PASSWORD: "password", CONF_PORT: 8278, CONF_VERIFY_SSL: False, - mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.CONF_ARP_PING: False, - mikrotik.CONF_DETECTION_TIME: timedelta(seconds=30), + CONF_FORCE_DHCP: False, + CONF_ARP_PING: False, + CONF_DETECTION_TIME: timedelta(seconds=30), } DEMO_CONFIG_ENTRY = { @@ -46,9 +51,9 @@ DEMO_CONFIG_ENTRY = { CONF_PASSWORD: "password", CONF_PORT: 8278, CONF_VERIFY_SSL: False, - mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.CONF_ARP_PING: False, - mikrotik.CONF_DETECTION_TIME: 30, + CONF_FORCE_DHCP: False, + CONF_ARP_PING: False, + CONF_DETECTION_TIME: 30, } @@ -78,29 +83,11 @@ def mock_api_connection_error(): yield -async def test_import(hass, api): - """Test import step.""" - result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=DEMO_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Home router" - assert result["data"][CONF_NAME] == "Home router" - assert result["data"][CONF_HOST] == "0.0.0.0" - assert result["data"][CONF_USERNAME] == "username" - assert result["data"][CONF_PASSWORD] == "password" - assert result["data"][CONF_PORT] == 8278 - assert result["data"][CONF_VERIFY_SSL] is False - - async def test_flow_works(hass, api): """Test config flow.""" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -118,11 +105,14 @@ async def test_flow_works(hass, api): assert result["data"][CONF_PORT] == 8278 -async def test_options(hass): +async def test_options(hass, api): """Test updating options.""" - entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -131,28 +121,28 @@ async def test_options(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mikrotik.CONF_DETECTION_TIME: 30, - mikrotik.CONF_ARP_PING: True, - mikrotik.const.CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: 30, + CONF_ARP_PING: True, + CONF_FORCE_DHCP: False, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { - mikrotik.CONF_DETECTION_TIME: 30, - mikrotik.CONF_ARP_PING: True, - mikrotik.const.CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: 30, + CONF_ARP_PING: True, + CONF_FORCE_DHCP: False, } async def test_host_already_configured(hass, auth_error): """Test host already configured.""" - entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT @@ -164,13 +154,13 @@ async def test_host_already_configured(hass, auth_error): async def test_name_exists(hass, api): """Test name already configured.""" - entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) user_input = DEMO_USER_INPUT.copy() user_input[CONF_HOST] = "0.0.0.1" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=user_input @@ -184,7 +174,7 @@ async def test_connection_error(hass, conn_error): """Test error when connection is unsuccessful.""" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT @@ -197,7 +187,7 @@ async def test_wrong_credentials(hass, auth_error): """Test error when credentials are wrong.""" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 715826e69d6..fbbb016d09f 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -9,7 +9,15 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA +from . import ( + DEVICE_2_WIRELESS, + DEVICE_3_DHCP_NUMERIC_NAME, + DEVICE_3_WIRELESS, + DHCP_DATA, + MOCK_DATA, + MOCK_OPTIONS, + WIRELESS_DATA, +) from .test_hub import setup_mikrotik_entry from tests.common import MockConfigEntry, patch @@ -27,6 +35,7 @@ def mock_device_registry_devices(hass): ( "00:00:00:00:00:01", "00:00:00:00:00:02", + "00:00:00:00:00:03", ) ): dev_reg.async_get_or_create( @@ -81,7 +90,7 @@ async def test_device_trackers(hass, mock_device_registry_devices): # test device_2 is added after connecting to wireless network WIRELESS_DATA.append(DEVICE_2_WIRELESS) - await hub.async_update() + await hub.async_refresh() await hass.async_block_till_done() device_2 = hass.states.get("device_tracker.device_2") @@ -108,13 +117,31 @@ async def test_device_trackers(hass, mock_device_registry_devices): hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( minutes=5 ) - await hub.async_update() + await hub.async_refresh() await hass.async_block_till_done() device_2 = hass.states.get("device_tracker.device_2") assert device_2.state == "not_home" +async def test_device_trackers_numerical_name(hass, mock_device_registry_devices): + """Test device_trackers created by mikrotik with numerical device name.""" + + await setup_mikrotik_entry( + hass, dhcp_data=[DEVICE_3_DHCP_NUMERIC_NAME], wireless_data=[DEVICE_3_WIRELESS] + ) + + device_3 = hass.states.get("device_tracker.123") + assert device_3 is not None + assert device_3.state == "home" + assert device_3.attributes["friendly_name"] == "123" + assert device_3.attributes["ip"] == "0.0.0.3" + assert "ip_address" not in device_3.attributes + assert device_3.attributes["mac"] == "00:00:00:00:00:03" + assert device_3.attributes["host_name"] == 123 + assert "mac_address" not in device_3.attributes + + async def test_restoring_devices(hass): """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = MockConfigEntry( diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index 2159b58293b..1e056071236 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -1,9 +1,6 @@ """Test Mikrotik hub.""" from unittest.mock import patch -import librouteros - -from homeassistant import config_entries from homeassistant.components import mikrotik from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA @@ -47,63 +44,6 @@ async def setup_mikrotik_entry(hass, **kwargs): return hass.data[mikrotik.DOMAIN][config_entry.entry_id] -async def test_hub_setup_successful(hass): - """Successful setup of Mikrotik hub.""" - with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", - return_value=True, - ) as forward_entry_setup: - hub = await setup_mikrotik_entry(hass) - - assert hub.config_entry.data == { - mikrotik.CONF_NAME: "Mikrotik", - mikrotik.CONF_HOST: "0.0.0.0", - mikrotik.CONF_USERNAME: "user", - mikrotik.CONF_PASSWORD: "pass", - mikrotik.CONF_PORT: 8278, - mikrotik.CONF_VERIFY_SSL: False, - } - assert hub.config_entry.options == { - mikrotik.hub.CONF_FORCE_DHCP: False, - mikrotik.CONF_ARP_PING: False, - mikrotik.CONF_DETECTION_TIME: 300, - } - - assert hub.api.available is True - assert hub.signal_update == "mikrotik-update-0.0.0.0" - assert forward_entry_setup.mock_calls[0][1] == (hub.config_entry, "device_tracker") - - -async def test_hub_setup_failed(hass): - """Failed setup of Mikrotik hub.""" - - config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) - config_entry.add_to_hass(hass) - # error when connection fails - with patch( - "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed - ): - - await hass.config_entries.async_setup(config_entry.entry_id) - - assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY - - # error when username or password is invalid - config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" - ) as forward_entry_setup, patch( - "librouteros.connect", - side_effect=librouteros.exceptions.TrapError("invalid user name or password"), - ): - - result = await hass.config_entries.async_setup(config_entry.entry_id) - - assert result is False - assert len(forward_entry_setup.mock_calls) == 0 - - async def test_update_failed(hass): """Test failing to connect during update.""" @@ -112,9 +52,9 @@ async def test_update_failed(hass): with patch.object( mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect ): - await hub.async_update() + await hub.async_refresh() - assert hub.api.available is False + assert not hub.last_update_success async def test_hub_not_support_wireless(hass): diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index bc00602789c..5ac408928d8 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -1,7 +1,12 @@ """Test Mikrotik setup process.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch + +from librouteros.exceptions import ConnectionClosed, LibRouterosError +import pytest from homeassistant.components import mikrotik +from homeassistant.components.mikrotik.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.setup import async_setup_component from . import MOCK_DATA @@ -9,6 +14,15 @@ from . import MOCK_DATA from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_api(): + """Mock api.""" + with patch("librouteros.create_transport"), patch( + "librouteros.Api.readResponse" + ) as mock_api: + yield mock_api + + async def test_setup_with_no_config(hass): """Test that we do not discover anything or try to set up a hub.""" assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True @@ -22,37 +36,13 @@ async def test_successful_config_entry(hass): data=MOCK_DATA, ) entry.add_to_hass(hass) - mock_registry = Mock() - with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( - "homeassistant.components.mikrotik.dr.async_get", - return_value=mock_registry, - ): - mock_hub.return_value.async_setup = AsyncMock(return_value=True) - mock_hub.return_value.serial_num = "12345678" - mock_hub.return_value.model = "RB750" - mock_hub.return_value.hostname = "mikrotik" - mock_hub.return_value.firmware = "3.65" - assert await mikrotik.async_setup_entry(hass, entry) is True - - assert len(mock_hub.mock_calls) == 2 - p_hass, p_entry = mock_hub.mock_calls[0][1] - - assert p_hass is hass - assert p_entry is entry - - assert len(mock_registry.mock_calls) == 1 - assert mock_registry.mock_calls[0][2] == { - "config_entry_id": entry.entry_id, - "connections": {("mikrotik", "12345678")}, - "manufacturer": mikrotik.ATTR_MANUFACTURER, - "model": "RB750", - "name": "mikrotik", - "sw_version": "3.65", - } + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.LOADED + assert hass.data[DOMAIN][entry.entry_id] -async def test_hub_fail_setup(hass): +async def test_hub_conn_error(hass, mock_api): """Test that a failed setup will not store the hub.""" entry = MockConfigEntry( domain=mikrotik.DOMAIN, @@ -60,14 +50,29 @@ async def test_hub_fail_setup(hass): ) entry.add_to_hass(hass) - with patch.object(mikrotik, "MikrotikHub") as mock_hub: - mock_hub.return_value.async_setup = AsyncMock(return_value=False) - assert await mikrotik.async_setup_entry(hass, entry) is False + mock_api.side_effect = ConnectionClosed - assert mikrotik.DOMAIN not in hass.data + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass): +async def test_hub_auth_error(hass, mock_api): + """Test that a failed setup will not store the hub.""" + entry = MockConfigEntry( + domain=mikrotik.DOMAIN, + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + mock_api.side_effect = LibRouterosError("invalid user name or password") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry(hass) -> None: """Test being able to unload an entry.""" entry = MockConfigEntry( domain=mikrotik.DOMAIN, @@ -75,18 +80,11 @@ async def test_unload_entry(hass): ) entry.add_to_hass(hass) - with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( - "homeassistant.helpers.device_registry.async_get", - return_value=Mock(), - ): - mock_hub.return_value.async_setup = AsyncMock(return_value=True) - mock_hub.return_value.serial_num = "12345678" - mock_hub.return_value.model = "RB750" - mock_hub.return_value.hostname = "mikrotik" - mock_hub.return_value.firmware = "3.65" - assert await mikrotik.async_setup_entry(hass, entry) is True + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(mock_hub.return_value.mock_calls) == 1 + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() - assert await mikrotik.async_unload_entry(hass, entry) - assert entry.entry_id not in hass.data[mikrotik.DOMAIN] + assert entry.state == ConfigEntryState.NOT_LOADED + assert entry.entry_id not in hass.data[DOMAIN] diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index e143b26e47f..72728ac20b6 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config as hass_config from homeassistant.components.min_max.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, @@ -76,6 +77,7 @@ async def test_min_sensor(hass): assert str(float(MIN_VALUE)) == state.state assert entity_ids[2] == state.attributes.get("min_entity_id") + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_max_sensor(hass): @@ -102,6 +104,7 @@ async def test_max_sensor(hass): assert str(float(MAX_VALUE)) == state.state assert entity_ids[1] == state.attributes.get("max_entity_id") + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_mean_sensor(hass): @@ -127,6 +130,7 @@ async def test_mean_sensor(hass): state = hass.states.get("sensor.test_mean") assert str(float(MEAN)) == state.state + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_mean_1_digit_sensor(hass): @@ -204,6 +208,7 @@ async def test_median_sensor(hass): state = hass.states.get("sensor.test_median") assert str(float(MEDIAN)) == state.state + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_not_enough_sensor_value(hass): @@ -327,6 +332,7 @@ async def test_last_sensor(hass): state = hass.states.get("sensor.test_last") assert str(float(value)) == state.state assert entity_id == state.attributes.get("last_entity_id") + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_reload(hass): diff --git a/tests/components/motion_blinds/test_gateway.py b/tests/components/motion_blinds/test_gateway.py new file mode 100644 index 00000000000..66f42c3c444 --- /dev/null +++ b/tests/components/motion_blinds/test_gateway.py @@ -0,0 +1,19 @@ +"""Test the Motion Blinds config flow.""" +from unittest.mock import Mock + +from motionblinds import DEVICE_TYPES_WIFI, BlindType + +from homeassistant.components.motion_blinds.gateway import device_name + +TEST_BLIND_MAC = "abcdefghujkl0001" + + +async def test_device_name(hass): + """test_device_name.""" + blind = Mock() + blind.blind_type = BlindType.RollerBlind.name + blind.mac = TEST_BLIND_MAC + assert device_name(blind) == "RollerBlind 0001" + + blind.device_type = DEVICE_TYPES_WIFI[0] + assert device_name(blind) == "RollerBlind" diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 9ef0f78874d..269a2b8a4c4 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -259,6 +259,7 @@ async def test_reauth(hass: HomeAssistant) -> None: "source": config_entries.SOURCE_REAUTH, "entry_id": config_entry.entry_id, }, + data=config_entry.data, ) assert result["type"] == "form" assert not result["errors"] diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 2b013ddf8dd..f4a72829046 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -30,6 +30,7 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -112,6 +113,15 @@ DEFAULT_CONFIG_REMOTE_CODE_TEXT = { } +@pytest.fixture(autouse=True) +def alarm_control_panel_platform_only(): + """Only setup the alarm_control_panel platform to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + yield + + async def test_fail_setup_without_state_topic(hass, mqtt_mock_entry_no_yaml_config): """Test for failing with no state topic.""" with assert_setup_component(0, alarm_control_panel.DOMAIN) as config: @@ -953,13 +963,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = alarm_control_panel.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index ebb1d78138f..20037a88d1c 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -62,6 +63,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def binary_sensor_platform_only(): + """Only setup the binary_sensor platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + async def test_setting_sensor_value_expires_availability_topic( hass, mqtt_mock_entry_with_yaml_config, caplog ): @@ -1063,13 +1071,11 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( assert "Skip state recovery after reload for binary_sensor.test3" in caplog.text -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = binary_sensor.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 35deccf2bfe..8748ef3be4d 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -5,7 +5,12 @@ from unittest.mock import patch import pytest from homeassistant.components import button -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + STATE_UNKNOWN, + Platform, +) from homeassistant.setup import async_setup_component from .test_common import ( @@ -41,6 +46,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def button_platform_only(): + """Only setup the button platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BUTTON]): + yield + + @pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") async def test_sending_mqtt_commands(hass, mqtt_mock_entry_with_yaml_config): """Test the sending MQTT commands.""" @@ -462,13 +474,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = button.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 54d829ce9f9..84bf4181a2c 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components import camera from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED +from homeassistant.const import Platform from homeassistant.setup import async_setup_component from .test_common import ( @@ -46,6 +47,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def camera_platform_only(): + """Only setup the camera platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CAMERA]): + yield + + async def test_run_camera_setup( hass, hass_client_no_auth, mqtt_mock_entry_with_yaml_config ): @@ -330,13 +338,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = camera.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 77843cee777..c633f267e76 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate.const import ( HVACMode, ) from homeassistant.components.mqtt.climate import MQTT_CLIMATE_ATTRIBUTES_BLOCKED -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.setup import async_setup_component from .test_common import ( @@ -106,6 +106,13 @@ DEFAULT_LEGACY_CONFIG = { } +@pytest.fixture(autouse=True) +def climate_platform_only(): + """Only setup the climate platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CLIMATE]): + yield + + async def test_setup_params(hass, mqtt_mock_entry_with_yaml_config): """Test the initial parameters.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) @@ -1866,13 +1873,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = CLIMATE_DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 24482129f3d..92feaa3c109 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1790,13 +1790,7 @@ async def help_test_reloadable_late(hass, caplog, tmp_path, domain, config): assert hass.states.get(f"{domain}.test_new_3") -async def help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - platform, - config, -): +async def help_test_setup_manual_entity_from_yaml(hass, platform, config): """Help to test setup from yaml through configuration entry.""" config_structure = {mqtt.DOMAIN: {platform: config}} diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c9784a81f80..6fe781335f0 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -235,7 +235,8 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "port": 1883, "username": "mock-user", "password": "mock-pass", - "protocol": "3.1.1", + "protocol": "3.1.1", # Set by the addon's discovery, ignored by HA + "ssl": False, # Set by the addon's discovery, ignored by HA } ), context={"source": config_entries.SOURCE_HASSIO}, @@ -255,7 +256,6 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "port": 1883, "username": "mock-user", "password": "mock-pass", - "protocol": "3.1.1", "discovery": True, } # Check we tried the connection diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 31e30ebf11a..c0d63cec1b4 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -43,6 +43,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -83,6 +84,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def cover_platform_only(): + """Only setup the cover platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.COVER]): + yield + + async def test_state_via_state_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( @@ -3348,13 +3356,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = cover.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 34042105af2..a9eb9b20825 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -1,8 +1,10 @@ """The tests for the MQTT device tracker platform using configuration.yaml.""" from unittest.mock import patch +import pytest + from homeassistant.components.device_tracker.const import DOMAIN, SOURCE_TYPE_BLUETOOTH -from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME +from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.setup import async_setup_component from .test_common import help_test_setup_manual_entity_from_yaml @@ -10,6 +12,13 @@ from .test_common import help_test_setup_manual_entity_from_yaml from tests.common import async_fire_mqtt_message +@pytest.fixture(autouse=True) +def device_tracker_platform_only(): + """Only setup the device_tracker platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.DEVICE_TRACKER]): + yield + + # Deprecated in HA Core 2022.6 async def test_legacy_ensure_device_tracker_platform_validation( hass, mqtt_mock_entry_with_yaml_config @@ -244,9 +253,7 @@ async def test_legacy_matching_source_type( assert hass.states.get(entity_id).attributes["source_type"] == SOURCE_TYPE_BLUETOOTH -async def test_setup_with_modern_schema( - hass, caplog, tmp_path, mock_device_tracker_conf -): +async def test_setup_with_modern_schema(hass, mock_device_tracker_conf): """Test setup using the modern schema.""" dev_id = "jan" entity_id = f"{DOMAIN}.{dev_id}" @@ -255,8 +262,6 @@ async def test_setup_with_modern_schema( hass.config.components = {"zone"} config = {"name": dev_id, "state_topic": topic} - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, DOMAIN, config - ) + await help_test_setup_manual_entity_from_yaml(hass, DOMAIN, config) assert hass.states.get(entity_id) is not None diff --git a/tests/components/mqtt/test_device_tracker_discovery.py b/tests/components/mqtt/test_device_tracker_discovery.py index 31853ad1dee..ac4058c9372 100644 --- a/tests/components/mqtt/test_device_tracker_discovery.py +++ b/tests/components/mqtt/test_device_tracker_discovery.py @@ -1,11 +1,13 @@ """The tests for the MQTT device_tracker discovery platform.""" +from unittest.mock import patch + import pytest from homeassistant.components import device_tracker from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN from homeassistant.components.mqtt.discovery import ALREADY_DISCOVERED -from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN, Platform from homeassistant.setup import async_setup_component from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message @@ -21,6 +23,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def device_tracker_platform_only(): + """Only setup the device_tracker platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.DEVICE_TRACKER]): + yield + + @pytest.fixture def device_reg(hass): """Return an empty, loaded, registry.""" diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index fe08c85a853..842e1dc4106 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -1,11 +1,13 @@ """The tests for MQTT device triggers.""" import json +from unittest.mock import patch import pytest import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component @@ -39,6 +41,16 @@ def calls(hass): return async_mock_service(hass, "test", "automation") +@pytest.fixture(autouse=True) +def binary_sensor_and_sensor_only(): + """Only setup the binary_sensor and sensor platform to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", + [Platform.BINARY_SENSOR, Platform.SENSOR], + ): + yield + + async def test_get_triggers( hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 65399a22f70..8cc5d0b1070 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -1,11 +1,12 @@ """Test MQTT diagnostics.""" import json -from unittest.mock import ANY +from unittest.mock import ANY, patch import pytest from homeassistant.components import mqtt +from homeassistant.const import Platform from tests.common import async_fire_mqtt_message, mock_device_registry from tests.components.diagnostics import ( @@ -31,6 +32,16 @@ default_config = { } +@pytest.fixture(autouse=True) +def device_tracker_sensor_only(): + """Only setup the device_tracker and sensor platforms to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", + [Platform.DEVICE_TRACKER, Platform.SENSOR], + ): + yield + + @pytest.fixture def device_reg(hass): """Return an empty, loaded, registry.""" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index df20dc031d0..d185d3334d0 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -18,6 +18,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -65,6 +66,7 @@ async def test_subscribing_config_topic(hass, mqtt_mock_entry_no_yaml_config): assert discovery_topic + "/+/+/+/config" in topics +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) @pytest.mark.parametrize( "topic, log", [ @@ -92,6 +94,7 @@ async def test_invalid_topic(hass, mqtt_mock_entry_no_yaml_config, caplog, topic caplog.clear() +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_invalid_json(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in invalid JSON.""" await mqtt_mock_entry_no_yaml_config() @@ -131,6 +134,7 @@ async def test_only_valid_components(hass, mqtt_mock_entry_no_yaml_config, caplo assert not mock_dispatcher_send.called +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_correct_config_discovery(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in correct JSON.""" await mqtt_mock_entry_no_yaml_config() @@ -148,6 +152,7 @@ async def test_correct_config_discovery(hass, mqtt_mock_entry_no_yaml_config, ca assert ("binary_sensor", "bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]) async def test_discover_fan(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovering an MQTT fan.""" await mqtt_mock_entry_no_yaml_config() @@ -165,6 +170,7 @@ async def test_discover_fan(hass, mqtt_mock_entry_no_yaml_config, caplog): assert ("fan", "bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CLIMATE]) async def test_discover_climate(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovering an MQTT climate component.""" await mqtt_mock_entry_no_yaml_config() @@ -184,6 +190,7 @@ async def test_discover_climate(hass, mqtt_mock_entry_no_yaml_config, caplog): assert ("climate", "bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) async def test_discover_alarm_control_panel( hass, mqtt_mock_entry_no_yaml_config, caplog ): @@ -365,6 +372,7 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_discovery_incl_nodeid(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in correct JSON with optional node_id included.""" await mqtt_mock_entry_no_yaml_config() @@ -382,6 +390,7 @@ async def test_discovery_incl_nodeid(hass, mqtt_mock_entry_no_yaml_config, caplo assert ("binary_sensor", "my_node_id bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_non_duplicate_discovery(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test for a non duplicate component.""" await mqtt_mock_entry_no_yaml_config() @@ -406,6 +415,7 @@ async def test_non_duplicate_discovery(hass, mqtt_mock_entry_no_yaml_config, cap assert "Component has already been discovered: binary_sensor bla" in caplog.text +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of component through empty discovery message.""" await mqtt_mock_entry_no_yaml_config() @@ -424,6 +434,7 @@ async def test_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): assert state is None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test rediscover of removed component.""" await mqtt_mock_entry_no_yaml_config() @@ -451,6 +462,7 @@ async def test_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): assert state is not None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate rediscover of removed component.""" await mqtt_mock_entry_no_yaml_config() @@ -500,6 +512,7 @@ async def test_rapid_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): assert events[4].data["old_state"] is None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_rediscover_unique(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate rediscover of removed component.""" await mqtt_mock_entry_no_yaml_config() @@ -559,6 +572,7 @@ async def test_rapid_rediscover_unique(hass, mqtt_mock_entry_no_yaml_config, cap assert events[3].data["old_state"] is None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_reconfigure(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate reconfigure of added component.""" await mqtt_mock_entry_no_yaml_config() @@ -611,6 +625,7 @@ async def test_rapid_reconfigure(hass, mqtt_mock_entry_no_yaml_config, caplog): assert events[2].data["new_state"].attributes["friendly_name"] == "Wine" +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_duplicate_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test for a non duplicate component.""" await mqtt_mock_entry_no_yaml_config() @@ -688,6 +703,7 @@ async def test_cleanup_device( ) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_cleanup_device_mqtt( hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): @@ -730,6 +746,7 @@ async def test_cleanup_device_mqtt( mqtt_mock.async_publish.assert_not_called() +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_cleanup_device_multiple_config_entries( hass, hass_ws_client, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): @@ -905,6 +922,7 @@ async def test_cleanup_device_multiple_config_entries_mqtt( mqtt_mock.async_publish.assert_not_called() +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of abbreviated discovery payload.""" await mqtt_mock_entry_no_yaml_config() @@ -963,6 +981,7 @@ async def test_discovery_expansion(hass, mqtt_mock_entry_no_yaml_config, caplog) assert state.state == STATE_UNAVAILABLE +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion_2(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of abbreviated discovery payload.""" await mqtt_mock_entry_no_yaml_config() @@ -1003,6 +1022,7 @@ async def test_discovery_expansion_2(hass, mqtt_mock_entry_no_yaml_config, caplo assert state.state == STATE_UNKNOWN +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) @pytest.mark.no_fail_on_log_exception async def test_discovery_expansion_3(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of broken discovery payload.""" @@ -1084,6 +1104,7 @@ async def test_discovery_expansion_without_encoding_and_value_template_1( assert state.state == STATE_UNAVAILABLE +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion_without_encoding_and_value_template_2( hass, mqtt_mock_entry_no_yaml_config, caplog ): @@ -1190,6 +1211,7 @@ async def test_missing_discover_abbreviations( assert not missing +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_no_implicit_state_topic_switch( hass, mqtt_mock_entry_no_yaml_config, caplog ): @@ -1214,6 +1236,7 @@ async def test_no_implicit_state_topic_switch( assert state.state == STATE_UNKNOWN +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) @pytest.mark.parametrize( "mqtt_config", [ @@ -1242,6 +1265,7 @@ async def test_complex_discovery_topic_prefix( assert ("binary_sensor", "node1 object1") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_mqtt_integration_discovery_subscribe_unsubscribe( hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): @@ -1283,6 +1307,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( assert not mqtt_client_mock.unsubscribe.called +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_mqtt_discovery_unsubscribe_once( hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 145edf5ac7d..b9ca5e3888d 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -28,6 +28,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -74,6 +75,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def fan_platform_only(): + """Only setup the fan platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]): + yield + + async def test_fail_setup_if_no_command_topic( hass, caplog, mqtt_mock_entry_no_yaml_config ): @@ -1894,13 +1902,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = fan.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index cea5bfa1787..0301e9e0481 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -30,6 +30,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -76,6 +77,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def humidifer_platform_only(): + """Only setup the humidifer platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.HUMIDIFIER]): + yield + + async def async_turn_on( hass, entity_id=ENTITY_MATCH_ALL, @@ -1268,15 +1276,13 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = humidifier.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index eb392f8c4f8..b435798c241 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -22,6 +22,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, + Platform, ) import homeassistant.core as ha from homeassistant.core import CoreState, HomeAssistant, callback @@ -51,6 +52,16 @@ class RecordCallsPartial(partial): __name__ = "RecordCallPartialTest" +@pytest.fixture(autouse=True) +def sensor_platforms_only(): + """Only setup the sensor platforms to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", + [Platform.SENSOR, Platform.BINARY_SENSOR], + ): + yield + + @pytest.fixture(autouse=True) def mock_storage(hass_storage): """Autouse hass_storage for the TestCase tests.""" @@ -1301,6 +1312,20 @@ async def test_publish_error(hass, caplog): assert "Failed to connect to MQTT server: Out of memory." in caplog.text +async def test_subscribe_error( + hass, caplog, mqtt_mock_entry_no_yaml_config, mqtt_client_mock +): + """Test publish error.""" + await mqtt_mock_entry_no_yaml_config() + mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) + await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + # simulate client is not connected error before subscribing + mqtt_client_mock.subscribe.side_effect = lambda *args: (4, None) + await mqtt.async_subscribe(hass, "some-topic", lambda *args: 0) + await hass.async_block_till_done() + + async def test_handle_message_callback( hass, caplog, mqtt_mock_entry_no_yaml_config, mqtt_client_mock ): @@ -1362,50 +1387,35 @@ async def test_setup_override_configuration(hass, caplog, tmp_path): assert calls_username_password_set[0][1] == "somepassword" -async def test_setup_manual_mqtt_with_platform_key(hass, caplog, tmp_path): +@patch("homeassistant.components.mqtt.PLATFORMS", []) +async def test_setup_manual_mqtt_with_platform_key(hass, caplog): """Test set up a manual MQTT item with a platform key.""" config = {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} with pytest.raises(AssertionError): - await help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - "light", - config, - ) + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert ( "Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" in caplog.text ) -async def test_setup_manual_mqtt_with_invalid_config(hass, caplog, tmp_path): +@patch("homeassistant.components.mqtt.PLATFORMS", []) +async def test_setup_manual_mqtt_with_invalid_config(hass, caplog): """Test set up a manual MQTT item with an invalid config.""" config = {"name": "test"} with pytest.raises(AssertionError): - await help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - "light", - config, - ) + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']." " Got None. (See ?, line ?)" in caplog.text ) -async def test_setup_manual_mqtt_empty_platform(hass, caplog, tmp_path): +@patch("homeassistant.components.mqtt.PLATFORMS", []) +async def test_setup_manual_mqtt_empty_platform(hass, caplog): """Test set up a manual MQTT platform without items.""" - config = None - await help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - "light", - config, - ) + config = [] + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert "voluptuous.error.MultipleInvalid" not in caplog.text @@ -1428,6 +1438,7 @@ async def test_setup_mqtt_client_protocol(hass): @patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2) +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_handle_mqtt_timeout_on_callback(hass, caplog): """Test publish without receiving an ACK callback.""" mid = 0 @@ -1768,9 +1779,12 @@ async def test_mqtt_subscribes_topics_on_connect( assert mqtt_client_mock.disconnect.call_count == 0 - expected = {"topic/test": 0, "home/sensor": 2, "still/pending": 1} - calls = {call[1][1]: call[1][2] for call in hass.add_job.mock_calls} - assert calls == expected + assert len(hass.add_job.mock_calls) == 1 + assert set(hass.add_job.mock_calls[0][1][1]) == { + ("home/sensor", 2), + ("still/pending", 1), + ("topic/test", 0), + } async def test_setup_entry_with_config_override( @@ -1786,13 +1800,12 @@ async def test_setup_entry_with_config_override( # mqtt present in yaml config assert await async_setup_component(hass, mqtt.DOMAIN, {}) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() # User sets up a config entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) - with patch("homeassistant.components.mqtt.PLATFORMS", []): - assert await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() # Discover a device to verify the entry was setup correctly async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) @@ -2038,6 +2051,7 @@ async def test_mqtt_ws_get_device_debug_info( assert response["result"] == expected_result +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CAMERA]) async def test_mqtt_ws_get_device_debug_info_binary( hass, device_reg, hass_ws_client, mqtt_mock_entry_no_yaml_config ): diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 43b6e839904..224e81781cf 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -29,7 +29,7 @@ from homeassistant.components.vacuum import ( ATTR_STATUS, VacuumEntityFeature, ) -from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON, Platform from homeassistant.setup import async_setup_component from .test_common import ( @@ -89,6 +89,13 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {vacuum.DOMAIN: {"platform": "mqtt", "name": "test"}} +@pytest.fixture(autouse=True) +def vacuum_platform_only(): + """Only setup the vacuum platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VACUUM]): + yield + + async def test_default_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test that the correct supported features.""" assert await async_setup_component( @@ -966,13 +973,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = vacuum.DOMAIN config = deepcopy(DEFAULT_CONFIG) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 08d5432ba27..4d8d8f24a3c 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -209,6 +209,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -251,6 +252,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]): + yield + + async def test_fail_setup_if_no_command_topic(hass, mqtt_mock_entry_no_yaml_config): """Test if command fails with command topic.""" assert await async_setup_component( @@ -3787,13 +3795,11 @@ async def test_sending_mqtt_effect_command_with_template( assert state.attributes.get("effect") == "colorloop" -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = light.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c24c5e87937..b930de9b6c3 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -103,6 +103,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -150,6 +151,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]): + yield + + class JsonValidator: """Helper to compare JSON.""" @@ -680,7 +688,7 @@ async def test_sending_mqtt_commands_and_optimistic( await common.async_turn_on(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "ON"}', 2, False + "test_light_rgb/set", '{"state":"ON"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -701,7 +709,7 @@ async def test_sending_mqtt_commands_and_optimistic( await common.async_turn_off(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "OFF"}', 2, False + "test_light_rgb/set", '{"state":"OFF"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -830,7 +838,7 @@ async def test_sending_mqtt_commands_and_optimistic2( # Turn the light on await common.async_turn_on(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "ON"}', 2, False + "test_light_rgb/set", '{"state":"ON"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -840,7 +848,7 @@ async def test_sending_mqtt_commands_and_optimistic2( await common.async_turn_on(hass, "light.test", color_temp=90) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", - JsonValidator('{"state": "ON", "color_temp": 90}'), + JsonValidator('{"state":"ON","color_temp":90}'), 2, False, ) @@ -851,7 +859,7 @@ async def test_sending_mqtt_commands_and_optimistic2( # Turn the light off await common.async_turn_off(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "OFF"}', 2, False + "test_light_rgb/set", '{"state":"OFF"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -1996,7 +2004,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): light.DOMAIN, DEFAULT_CONFIG, light.SERVICE_TURN_ON, - command_payload='{"state": "ON"}', + command_payload='{"state":"ON"}', state_payload='{"state":"ON"}', ) @@ -2030,7 +2038,7 @@ async def test_max_mireds(hass, mqtt_mock_entry_with_yaml_config): light.SERVICE_TURN_ON, "command_topic", None, - '{"state": "ON"}', + '{"state":"ON"}', None, None, None, @@ -2039,7 +2047,7 @@ async def test_max_mireds(hass, mqtt_mock_entry_with_yaml_config): light.SERVICE_TURN_OFF, "command_topic", None, - '{"state": "OFF"}', + '{"state":"OFF"}', None, None, None, @@ -2146,13 +2154,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = light.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 0d4b95e9152..6e271d08651 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -41,6 +41,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -90,6 +91,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]): + yield + + @pytest.mark.parametrize( "test_config", [ @@ -1250,13 +1258,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = light.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index b48557efc8f..1bf4183e60f 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + Platform, ) from homeassistant.setup import async_setup_component @@ -58,6 +59,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def lock_platform_only(): + """Only setup the lock platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LOCK]): + yield + + async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( @@ -738,7 +746,5 @@ async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index a49b6de198d..1db7c5e3463 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -18,11 +18,15 @@ from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, + NumberDeviceClass, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + TEMP_FAHRENHEIT, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -57,13 +61,20 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.common import async_fire_mqtt_message +from tests.common import async_fire_mqtt_message, mock_restore_cache_with_extra_data DEFAULT_CONFIG = { number.DOMAIN: {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} } +@pytest.fixture(autouse=True) +def number_platform_only(): + """Only setup the number platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.NUMBER]): + yield + + async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload.""" topic = "test/number" @@ -76,7 +87,8 @@ async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): "state_topic": topic, "command_topic": topic, "name": "Test Number", - "unit_of_measurement": "my unit", + "device_class": "temperature", + "unit_of_measurement": TEMP_FAHRENHEIT, "payload_reset": "reset!", } }, @@ -89,16 +101,18 @@ async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): await hass.async_block_till_done() state = hass.states.get("number.test_number") - assert state.state == "10" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "my unit" + assert state.state == "-12.0" # 10 °F -> -12 °C + assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" async_fire_mqtt_message(hass, topic, "20.5") await hass.async_block_till_done() state = hass.states.get("number.test_number") - assert state.state == "20.5" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "my unit" + assert state.state == "-6.4" # 20.5 °F -> -6.4 °C + assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" async_fire_mqtt_message(hass, topic, "reset!") @@ -106,7 +120,8 @@ async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): state = hass.states.get("number.test_number") assert state.state == "unknown" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "my unit" + assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): @@ -150,29 +165,70 @@ async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): assert state.state == "unknown" +async def test_restore_native_value(hass, mqtt_mock_entry_with_yaml_config): + """Test that the stored native_value is restored.""" + topic = "test/number" + + RESTORE_DATA = { + "native_max_value": None, # Ignored by MQTT number + "native_min_value": None, # Ignored by MQTT number + "native_step": None, # Ignored by MQTT number + "native_unit_of_measurement": None, # Ignored by MQTT number + "native_value": 100.0, + } + + mock_restore_cache_with_extra_data( + hass, ((ha.State("number.test_number", "abc"), RESTORE_DATA),) + ) + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": topic, + "device_class": "temperature", + "unit_of_measurement": TEMP_FAHRENHEIT, + "name": "Test Number", + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get("number.test_number") + assert state.state == "37.8" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + async def test_run_number_service_optimistic(hass, mqtt_mock_entry_with_yaml_config): """Test that set_value service works in optimistic mode.""" topic = "test/number" - fake_state = ha.State("switch.test", "3") + RESTORE_DATA = { + "native_max_value": None, # Ignored by MQTT number + "native_min_value": None, # Ignored by MQTT number + "native_step": None, # Ignored by MQTT number + "native_unit_of_measurement": None, # Ignored by MQTT number + "native_value": 3, + } - with patch( - "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", - return_value=fake_state, - ): - assert await async_setup_component( - hass, - number.DOMAIN, - { - "number": { - "platform": "mqtt", - "command_topic": topic, - "name": "Test Number", - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache_with_extra_data( + hass, ((ha.State("number.test_number", "abc"), RESTORE_DATA),) + ) + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Number", + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -224,26 +280,31 @@ async def test_run_number_service_optimistic_with_command_template( """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/number" - fake_state = ha.State("switch.test", "3") + RESTORE_DATA = { + "native_max_value": None, # Ignored by MQTT number + "native_min_value": None, # Ignored by MQTT number + "native_step": None, # Ignored by MQTT number + "native_unit_of_measurement": None, # Ignored by MQTT number + "native_value": 3, + } - with patch( - "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", - return_value=fake_state, - ): - assert await async_setup_component( - hass, - number.DOMAIN, - { - "number": { - "platform": "mqtt", - "command_topic": topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache_with_extra_data( + hass, ((ha.State("number.test_number", "abc"), RESTORE_DATA),) + ) + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -784,13 +845,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = number.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index eb5cb94df2d..3036565dad5 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components import scene -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN, Platform import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -34,6 +34,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def scene_platform_only(): + """Only setup the scene platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SCENE]): + yield + + async def test_sending_mqtt_commands(hass, mqtt_mock_entry_with_yaml_config): """Test the sending MQTT commands.""" fake_state = ha.State("scene.test", STATE_UNKNOWN) @@ -222,13 +229,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = scene.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 888dd301018..c22bd43b86f 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -13,7 +13,12 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_UNKNOWN, + Platform, +) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -59,6 +64,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def select_platform_only(): + """Only setup the select platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SELECT]): + yield + + async def test_run_select_setup(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload.""" topic = "test/select" @@ -667,13 +679,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = select.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 7081ae45993..f30bcf43392 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + Platform, ) import homeassistant.core as ha from homeassistant.helpers import device_registry as dr @@ -72,6 +73,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def sensor_platform_only(): + """Only setup the sensor platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]): + yield + + async def test_setting_sensor_value_via_mqtt_message( hass, mqtt_mock_entry_with_yaml_config ): @@ -1197,13 +1205,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = sensor.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 2db2060c133..6da9682c1c7 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -55,6 +56,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def siren_platform_only(): + """Only setup the siren platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SIREN]): + yield + + async def async_turn_on(hass, entity_id=ENTITY_MATCH_ALL, parameters={}) -> None: """Turn all or specified siren on.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -132,7 +140,7 @@ async def test_sending_mqtt_commands_and_optimistic( await async_turn_on(hass, entity_id="siren.test") mqtt_mock.async_publish.assert_called_once_with( - "command-topic", '{"state": "beer on"}', 2, False + "command-topic", '{"state":"beer on"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("siren.test") @@ -141,7 +149,7 @@ async def test_sending_mqtt_commands_and_optimistic( await async_turn_off(hass, entity_id="siren.test") mqtt_mock.async_publish.assert_called_once_with( - "command-topic", '{"state": "beer off"}', 2, False + "command-topic", '{"state":"beer off"}', 2, False ) state = hass.states.get("siren.test") assert state.state == STATE_OFF @@ -862,7 +870,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): siren.DOMAIN, DEFAULT_CONFIG, siren.SERVICE_TURN_ON, - command_payload='{"state": "ON"}', + command_payload='{"state":"ON"}', ) @@ -873,14 +881,14 @@ async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): siren.SERVICE_TURN_ON, "command_topic", None, - '{"state": "ON"}', + '{"state":"ON"}', None, ), ( siren.SERVICE_TURN_OFF, "command_topic", None, - '{"state": "OFF"}', + '{"state":"OFF"}', None, ), ], @@ -959,13 +967,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = siren.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index f20a881dda1..b0b89c28646 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -31,6 +31,7 @@ from homeassistant.const import ( CONF_PLATFORM, ENTITY_MATCH_ALL, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -87,6 +88,13 @@ DEFAULT_CONFIG_2 = { } +@pytest.fixture(autouse=True) +def vacuum_platform_only(): + """Only setup the vacuum platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VACUUM]): + yield + + async def test_default_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test that the correct supported features.""" assert await async_setup_component( @@ -692,13 +700,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = vacuum.DOMAIN config = deepcopy(DEFAULT_CONFIG) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index 7c1663b9c09..3be66f0aa90 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -1,5 +1,7 @@ """The tests for the MQTT subscription component.""" -from unittest.mock import ANY +from unittest.mock import ANY, patch + +import pytest from homeassistant.components.mqtt.subscription import ( async_prepare_subscribe_topics, @@ -11,6 +13,13 @@ from homeassistant.core import callback from tests.common import async_fire_mqtt_message +@pytest.fixture(autouse=True) +def no_platforms(): + """Skip platform setup to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", []): + yield + + async def test_subscribe_topics(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test subscription to topics.""" await mqtt_mock_entry_no_yaml_config() diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index b217bf40c22..ba23efc859c 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -11,6 +11,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -53,6 +54,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def switch_platform_only(): + """Only setup the switch platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]): + yield + + async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( @@ -648,13 +656,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = switch.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 09be31011f2..f06dd6f5244 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -42,6 +43,13 @@ DEFAULT_TAG_SCAN_JSON = ( ) +@pytest.fixture(autouse=True) +def binary_sensor_only(): + """Only setup the binary_sensor platform to speed up test.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + @pytest.fixture def device_reg(hass): """Return an empty, loaded, registry.""" diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index a4079558c34..4c0a70707eb 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -1,5 +1,5 @@ """The tests for the MQTT automation.""" -from unittest.mock import ANY +from unittest.mock import ANY, patch import pytest @@ -17,6 +17,13 @@ def calls(hass): return async_mock_service(hass, "test", "automation") +@pytest.fixture(autouse=True) +def no_platforms(): + """Skip platform setup to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", []): + yield + + @pytest.fixture(autouse=True) async def setup_comp(hass, mqtt_mock_entry_no_yaml_config): """Initialize components.""" diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 54ae88cdccb..7c73ce1a389 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -13,13 +13,13 @@ import pytest from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.components.mysensors import CONF_VERSION, DEFAULT_BAUD_RATE +from homeassistant.components.mysensors.config_flow import DEFAULT_BAUD_RATE from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_SERIAL, - CONF_GATEWAYS, + CONF_VERSION, DOMAIN, ) from homeassistant.core import HomeAssistant @@ -141,8 +141,7 @@ async def integration_fixture( hass: HomeAssistant, transport: MagicMock, config_entry: MockConfigEntry ) -> AsyncGenerator[MockConfigEntry, None]: """Set up the mysensors integration with a config entry.""" - device = config_entry.data[CONF_DEVICE] - config: dict[str, Any] = {DOMAIN: {CONF_GATEWAYS: [{CONF_DEVICE: device}]}} + config: dict[str, Any] = {} config_entry.add_to_hass(hass) with patch("homeassistant.components.mysensors.device.UPDATE_DELAY", new=0): diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index ca13a6d9cef..e7808162043 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -14,7 +14,6 @@ from homeassistant.components.mysensors.const import ( CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, CONF_GATEWAY_TYPE_TCP, - CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, @@ -399,333 +398,268 @@ async def test_config_invalid( assert len(mock_setup_entry.mock_calls) == 0 -@pytest.mark.parametrize( - "user_input", - [ - { - CONF_DEVICE: "COM5", - CONF_BAUD_RATE: 57600, - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, - CONF_VERSION: "2.3", - CONF_PERSISTENCE_FILE: "bla.json", - }, - { - CONF_DEVICE: "COM5", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 57600, - CONF_TCP_PORT: 5003, - CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: True, - }, - { - CONF_DEVICE: "mqtt", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_TOPIC_IN_PREFIX: "intopic", - CONF_TOPIC_OUT_PREFIX: "outtopic", - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - }, - { - CONF_DEVICE: "127.0.0.1", - CONF_PERSISTENCE_FILE: "blub.pickle", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 343, - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - }, - ], -) -async def test_import(hass: HomeAssistant, mqtt: None, user_input: dict) -> None: - """Test importing a gateway.""" - - with patch("sys.platform", "win32"), patch( - "homeassistant.components.mysensors.config_flow.try_connect", return_value=True - ), patch( - "homeassistant.components.mysensors.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, data=user_input, context={"source": config_entries.SOURCE_IMPORT} - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - - @pytest.mark.parametrize( "first_input, second_input, expected_result", [ ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "same2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "same2", }, - (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_IN_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "different1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "different3", CONF_TOPIC_OUT_PREFIX: "different4", }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different4", }, - (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_IN_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "different1", CONF_TOPIC_OUT_PREFIX: "same1", }, - (CONF_TOPIC_OUT_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_OUT_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different1", }, - (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_IN_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "127.0.0.1", CONF_PERSISTENCE_FILE: "same.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "same.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - ("persistence_file", "duplicate_persistence_file"), + FlowResult( + type="form", errors={"persistence_file": "duplicate_persistence_file"} + ), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "127.0.0.1", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "same.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "127.0.0.1", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different1.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different2.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - ("base", "already_configured"), + FlowResult(type="form", errors={"base": "already_configured"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different1.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different2.json", CONF_TCP_PORT: 5003, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_TCP_PORT: 5003, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.3", CONF_TCP_PORT: 5003, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different1.json", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different2.json", }, - ("base", "already_configured"), + FlowResult(type="form", errors={"base": "already_configured"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM6", CONF_BAUD_RATE: 57600, - CONF_RETAIN: True, CONF_VERSION: "2.3", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, CONF_VERSION: "2.3", }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", CONF_BAUD_RATE: 115200, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different1.json", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", CONF_BAUD_RATE: 57600, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different2.json", }, - ("base", "already_configured"), + FlowResult(type="form", errors={"base": "already_configured"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", CONF_BAUD_RATE: 115200, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "same.json", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM6", CONF_BAUD_RATE: 57600, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "same.json", }, - ("persistence_file", "duplicate_persistence_file"), + FlowResult( + type="form", errors={"persistence_file": "duplicate_persistence_file"} + ), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, CONF_VERSION: "1.4", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM6", CONF_PERSISTENCE_FILE: "bla2.json", CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, CONF_VERSION: "1.4", }, - None, + FlowResult(type="create_entry"), ), ], ) @@ -734,7 +668,7 @@ async def test_duplicate( mqtt: None, first_input: dict, second_input: dict, - expected_result: tuple[str, str] | None, + expected_result: FlowResult, ) -> None: """Test duplicate detection.""" @@ -746,12 +680,17 @@ async def test_duplicate( ): MockConfigEntry(domain=DOMAIN, data=first_input).add_to_hass(hass) + second_gateway_type = second_input.pop(CONF_GATEWAY_TYPE) result = await hass.config_entries.flow.async_init( - DOMAIN, data=second_input, context={"source": config_entries.SOURCE_IMPORT} + DOMAIN, + data={CONF_GATEWAY_TYPE: second_gateway_type}, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + second_input, ) await hass.async_block_till_done() - if expected_result is None: - assert result["type"] == "create_entry" - else: - assert result["type"] == "abort" - assert result["reason"] == expected_result[1] + + for key, val in expected_result.items(): + assert result[key] == val # type: ignore[literal-required] diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index bb5d77dc7e3..5d44cdbdb3c 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -2,360 +2,19 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any -from unittest.mock import patch from aiohttp import ClientWebSocketResponse from mysensors import BaseSyncGateway from mysensors.sensor import Sensor -import pytest -from homeassistant.components.mysensors import ( - CONF_BAUD_RATE, - CONF_DEVICE, - CONF_GATEWAYS, - CONF_PERSISTENCE, - CONF_PERSISTENCE_FILE, - CONF_RETAIN, - CONF_TCP_PORT, - CONF_VERSION, - DEFAULT_VERSION, - DOMAIN, -) -from homeassistant.components.mysensors.const import ( - CONF_GATEWAY_TYPE, - CONF_GATEWAY_TYPE_MQTT, - CONF_GATEWAY_TYPE_SERIAL, - CONF_GATEWAY_TYPE_TCP, - CONF_TOPIC_IN_PREFIX, - CONF_TOPIC_OUT_PREFIX, -) +from homeassistant.components.mysensors import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -@pytest.mark.parametrize( - "config, expected_calls, expected_to_succeed, expected_config_entry_data", - [ - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "COM5", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 57600, - CONF_TCP_PORT: 5003, - } - ], - CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: True, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - CONF_DEVICE: "COM5", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 57600, - CONF_VERSION: "2.3", - CONF_TCP_PORT: 5003, - CONF_TOPIC_IN_PREFIX: "", - CONF_TOPIC_OUT_PREFIX: "", - CONF_RETAIN: True, - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "127.0.0.1", - CONF_PERSISTENCE_FILE: "blub.pickle", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 343, - } - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, - CONF_DEVICE: "127.0.0.1", - CONF_PERSISTENCE_FILE: "blub.pickle", - CONF_TCP_PORT: 343, - CONF_VERSION: "2.4", - CONF_BAUD_RATE: 115200, - CONF_TOPIC_IN_PREFIX: "", - CONF_TOPIC_OUT_PREFIX: "", - CONF_RETAIN: False, - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "127.0.0.1", - } - ], - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, - CONF_DEVICE: "127.0.0.1", - CONF_TCP_PORT: 5003, - CONF_VERSION: DEFAULT_VERSION, - CONF_BAUD_RATE: 115200, - CONF_TOPIC_IN_PREFIX: "", - CONF_TOPIC_OUT_PREFIX: "", - CONF_RETAIN: False, - CONF_PERSISTENCE_FILE: "mysensors1.pickle", - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_TOPIC_IN_PREFIX: "intopic", - CONF_TOPIC_OUT_PREFIX: "outtopic", - } - ], - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, - CONF_DEVICE: "mqtt", - CONF_VERSION: DEFAULT_VERSION, - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_TOPIC_OUT_PREFIX: "outtopic", - CONF_TOPIC_IN_PREFIX: "intopic", - CONF_RETAIN: False, - CONF_PERSISTENCE_FILE: "mysensors1.pickle", - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - } - ], - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 0, - True, - [{}], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_TOPIC_OUT_PREFIX: "out", - CONF_TOPIC_IN_PREFIX: "in", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - { - CONF_DEVICE: "COM6", - CONF_PERSISTENCE_FILE: "bla2.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 2, - True, - [ - { - CONF_DEVICE: "mqtt", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_TOPIC_OUT_PREFIX: "out", - CONF_TOPIC_IN_PREFIX: "in", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "2.4", - CONF_RETAIN: False, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, - }, - { - CONF_DEVICE: "COM6", - CONF_PERSISTENCE_FILE: "bla2.json", - CONF_TOPIC_OUT_PREFIX: "", - CONF_TOPIC_IN_PREFIX: "", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "2.4", - CONF_RETAIN: False, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - }, - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - { - CONF_DEVICE: "COM6", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 0, - False, - [{}], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "COMx", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 0, - True, - [{}], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "COM1", - }, - { - CONF_DEVICE: "COM2", - }, - ], - } - }, - 2, - True, - [ - { - CONF_DEVICE: "COM1", - CONF_PERSISTENCE_FILE: "mysensors1.pickle", - CONF_TOPIC_OUT_PREFIX: "", - CONF_TOPIC_IN_PREFIX: "", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "1.4", - CONF_RETAIN: True, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - }, - { - CONF_DEVICE: "COM2", - CONF_PERSISTENCE_FILE: "mysensors2.pickle", - CONF_TOPIC_OUT_PREFIX: "", - CONF_TOPIC_IN_PREFIX: "", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "1.4", - CONF_RETAIN: True, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - }, - ], - ), - ], -) -async def test_import( - hass: HomeAssistant, - mqtt: None, - config: ConfigType, - expected_calls: int, - expected_to_succeed: bool, - expected_config_entry_data: list[dict[str, Any]], -) -> None: - """Test importing a gateway.""" - - with patch("sys.platform", "win32"), patch( - "homeassistant.components.mysensors.config_flow.try_connect", return_value=True - ), patch( - "homeassistant.components.mysensors.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await async_setup_component(hass, DOMAIN, config) - assert result == expected_to_succeed - await hass.async_block_till_done() - - assert len(mock_setup_entry.mock_calls) == expected_calls - - for idx in range(expected_calls): - config_entry = mock_setup_entry.mock_calls[idx][1][1] - expected_persistence_file = expected_config_entry_data[idx].pop( - CONF_PERSISTENCE_FILE - ) - expected_persistence_path = hass.config.path(expected_persistence_file) - config_entry_data = dict(config_entry.data) - persistence_path = config_entry_data.pop(CONF_PERSISTENCE_FILE) - assert persistence_path == expected_persistence_path - assert config_entry_data == expected_config_entry_data[idx] - - async def test_remove_config_entry_device( hass: HomeAssistant, gps_sensor: Sensor, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 9479e29cdea..67274cf1c78 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -23,6 +23,8 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( ) VALID_CONFIG = {"host": "10.10.2.3"} VALID_AUTH = {"username": "fake_username", "password": "fake_password"} +DEVICE_CONFIG = {"www_basicauth_enabled": False} +DEVICE_CONFIG_AUTH = {"www_basicauth_enabled": True} async def test_form_create_entry_without_auth(hass): @@ -34,7 +36,10 @@ async def test_form_create_entry_without_auth(hass): assert result["step_id"] == SOURCE_USER assert result["errors"] == {} - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( @@ -62,24 +67,22 @@ async def test_form_create_entry_with_auth(hass): assert result["errors"] == {} with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", - side_effect=AuthFailed("Auth Error"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - VALID_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "credentials" - - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( "homeassistant.components.nam.async_setup_entry", return_value=True ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + result = await hass.config_entries.flow.async_configure( result["flow_id"], VALID_AUTH, @@ -104,7 +107,10 @@ async def test_reauth_successful(hass): ) entry.add_to_hass(hass) - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -137,7 +143,7 @@ async def test_reauth_unsuccessful(hass): entry.add_to_hass(hass) with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_init( @@ -171,8 +177,11 @@ async def test_form_with_auth_errors(hass, error): """Test we handle errors when auth is required.""" exc, base_error = error with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Auth Error"), + ), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -221,26 +230,13 @@ async def test_form_errors(hass, error): async def test_form_abort(hass): - """Test we handle abort after error.""" - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - side_effect=CannotGetMac("Cannot get MAC address from device"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "device_unsupported" - - -async def test_form_with_auth_abort(hass): """Test we handle abort after error.""" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", - side_effect=AuthFailed("Auth Error"), + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=CannotGetMac("Cannot get MAC address from device"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -248,18 +244,6 @@ async def test_form_with_auth_abort(hass): data=VALID_CONFIG, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "credentials" - - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - side_effect=CannotGetMac("Cannot get MAC address from device"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - VALID_AUTH, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "device_unsupported" @@ -275,7 +259,10 @@ async def test_form_already_configured(hass): DOMAIN, context={"source": SOURCE_USER} ) - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -293,7 +280,10 @@ async def test_form_already_configured(hass): async def test_zeroconf(hass): """Test we get the form.""" - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -332,8 +322,11 @@ async def test_zeroconf(hass): async def test_zeroconf_with_auth(hass): """Test that the zeroconf step with auth works.""" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Auth Error"), + ), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -351,7 +344,10 @@ async def test_zeroconf_with_auth(hass): assert result["errors"] == {} assert context["title_placeholders"]["host"] == "10.10.2.3" - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 3223a394f68..9eac901d693 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -51,7 +51,7 @@ async def test_config_auth_failed(hass): ) with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Authorization has failed"), ): entry.add_to_hass(hass) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index c2a9c6db157..f86112ada75 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -1,11 +1,12 @@ """Common libraries for test setup.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable import copy -from dataclasses import dataclass +from dataclasses import dataclass, field import time from typing import Any, Generator, TypeVar -from unittest.mock import patch from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.device import Device @@ -14,9 +15,9 @@ from google_nest_sdm.event import EventMessage from google_nest_sdm.event_media import CachePolicy from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber +from homeassistant.components.application_credentials import ClientCredential from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import SDM_SCOPES -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -25,10 +26,15 @@ PlatformSetup = Callable[[], Awaitable[None]] _T = TypeVar("_T") YieldFixture = Generator[_T, None, None] +WEB_AUTH_DOMAIN = DOMAIN +APP_AUTH_DOMAIN = f"{DOMAIN}.installed" + PROJECT_ID = "some-project-id" CLIENT_ID = "some-client-id" CLIENT_SECRET = "some-client-secret" -SUBSCRIBER_ID = "projects/example/subscriptions/subscriber-id-9876" +CLOUD_PROJECT_ID = "cloud-id-9876" +SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" + CONFIG = { "nest": { @@ -70,8 +76,10 @@ def create_config_entry(token_expiration_time=None) -> MockConfigEntry: class NestTestConfig: """Holder for integration configuration.""" - config: dict[str, Any] - config_entry_data: dict[str, Any] + config: dict[str, Any] = field(default_factory=dict) + config_entry_data: dict[str, Any] | None = None + auth_implementation: str = WEB_AUTH_DOMAIN + credential: ClientCredential | None = None # Exercises mode where all configuration is in configuration.yaml @@ -79,10 +87,12 @@ TEST_CONFIG_YAML_ONLY = NestTestConfig( config=CONFIG, config_entry_data={ "sdm": {}, - "auth_implementation": "nest", "token": create_token_entry(), }, ) +TEST_CONFIGFLOW_YAML_ONLY = NestTestConfig( + config=TEST_CONFIG_YAML_ONLY.config, +) # Exercises mode where subscriber id is created in the config flow, but # all authentication is defined in configuration.yaml @@ -96,11 +106,30 @@ TEST_CONFIG_HYBRID = NestTestConfig( }, config_entry_data={ "sdm": {}, - "auth_implementation": "nest", "token": create_token_entry(), + "cloud_project_id": CLOUD_PROJECT_ID, "subscriber_id": SUBSCRIBER_ID, }, ) +TEST_CONFIGFLOW_HYBRID = NestTestConfig(TEST_CONFIG_HYBRID.config) + +# Exercises mode where all configuration is from the config flow +TEST_CONFIG_APP_CREDS = NestTestConfig( + config_entry_data={ + "sdm": {}, + "token": create_token_entry(), + "project_id": PROJECT_ID, + "cloud_project_id": CLOUD_PROJECT_ID, + "subscriber_id": SUBSCRIBER_ID, + }, + auth_implementation="imported-cred", + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), +) +TEST_CONFIGFLOW_APP_CREDS = NestTestConfig( + config=TEST_CONFIG_APP_CREDS.config, + auth_implementation="imported-cred", + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), +) TEST_CONFIG_LEGACY = NestTestConfig( config={ @@ -119,6 +148,17 @@ TEST_CONFIG_LEGACY = NestTestConfig( }, }, ) +TEST_CONFIG_ENTRY_LEGACY = NestTestConfig( + config_entry_data={ + "auth_implementation": "local", + "tokens": { + "expires_at": time.time() + 86400, + "access_token": { + "token": "some-token", + }, + }, + }, +) class FakeSubscriber(GoogleNestSubscriber): @@ -188,29 +228,3 @@ class CreateDevice: data.update(raw_data if raw_data else {}) data["traits"].update(raw_traits if raw_traits else {}) self.device_manager.add_device(Device.MakeDevice(data, auth=self.auth)) - - -async def async_setup_sdm_platform( - hass, - platform, - devices={}, -): - """Set up the platform and prerequisites.""" - create_config_entry().add_to_hass(hass) - subscriber = FakeSubscriber() - device_manager = await subscriber.async_get_device_manager() - if devices: - for device in devices.values(): - device_manager.add_device(device) - platforms = [] - if platform: - platforms = [platform] - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", platforms), patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - return subscriber diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 9b060d38fbe..458685cde70 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -5,7 +5,7 @@ from collections.abc import Generator import copy import shutil from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import aiohttp @@ -14,6 +14,9 @@ from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.device_manager import DeviceManager import pytest +from homeassistant.components.application_credentials import ( + async_import_client_credential, +) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID from homeassistant.core import HomeAssistant @@ -21,8 +24,9 @@ from homeassistant.setup import async_setup_component from .common import ( DEVICE_ID, + PROJECT_ID, SUBSCRIBER_ID, - TEST_CONFIG_HYBRID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_YAML_ONLY, CreateDevice, FakeSubscriber, @@ -114,6 +118,17 @@ def subscriber() -> YieldFixture[FakeSubscriber]: yield subscriber +@pytest.fixture +def mock_subscriber() -> YieldFixture[AsyncMock]: + """Fixture for injecting errors into the subscriber.""" + mock_subscriber = AsyncMock(FakeSubscriber) + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=mock_subscriber, + ): + yield mock_subscriber + + @pytest.fixture async def device_manager(subscriber: FakeSubscriber) -> DeviceManager: """Set up the DeviceManager.""" @@ -170,9 +185,15 @@ def subscriber_id() -> str: return SUBSCRIBER_ID +@pytest.fixture +def auth_implementation(nest_test_config: NestTestConfig) -> str | None: + """Fixture to let tests override the auth implementation in the config entry.""" + return nest_test_config.auth_implementation + + @pytest.fixture( - params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_HYBRID], - ids=["yaml-config-only", "hybrid-config"], + params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_APP_CREDS], + ids=["yaml-config-only", "app-creds"], ) def nest_test_config(request) -> NestTestConfig: """Fixture that sets up the configuration used for the test.""" @@ -193,9 +214,18 @@ def config( return config +@pytest.fixture +def config_entry_unique_id() -> str: + """Fixture to set ConfigEntry unique id.""" + return PROJECT_ID + + @pytest.fixture def config_entry( - subscriber_id: str | None, nest_test_config: NestTestConfig + subscriber_id: str | None, + auth_implementation: str | None, + nest_test_config: NestTestConfig, + config_entry_unique_id: str, ) -> MockConfigEntry | None: """Fixture that sets up the ConfigEntry for the test.""" if nest_test_config.config_entry_data is None: @@ -206,7 +236,22 @@ def config_entry( data[CONF_SUBSCRIBER_ID] = subscriber_id else: del data[CONF_SUBSCRIBER_ID] - return MockConfigEntry(domain=DOMAIN, data=data) + data["auth_implementation"] = auth_implementation + return MockConfigEntry(domain=DOMAIN, data=data, unique_id=config_entry_unique_id) + + +@pytest.fixture(autouse=True) +async def credential(hass: HomeAssistant, nest_test_config: NestTestConfig) -> None: + """Fixture that provides the ClientCredential for the test if any.""" + if not nest_test_config.credential: + return + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + nest_test_config.credential, + nest_test_config.auth_implementation, + ) @pytest.fixture @@ -219,9 +264,7 @@ async def setup_base_platform( """Fixture to setup the integration platform.""" if config_entry: config_entry.add_to_hass(hass) - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", platforms): + with patch("homeassistant.components.nest.PLATFORMS", platforms): async def _setup_func() -> bool: assert await async_setup_component(hass, DOMAIN, config) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 894fda09a8f..7d88ba1d329 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -11,6 +11,8 @@ The tests below exercise both cases during integration setup. import time from unittest.mock import patch +import pytest + from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import API_URL, OAUTH2_TOKEN, SDM_SCOPES from homeassistant.setup import async_setup_component @@ -23,6 +25,7 @@ from .common import ( FAKE_REFRESH_TOKEN, FAKE_TOKEN, PROJECT_ID, + TEST_CONFIGFLOW_YAML_ONLY, create_config_entry, ) @@ -35,6 +38,7 @@ async def async_setup_sdm(hass): await hass.async_block_till_done() +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth(hass, aioclient_mock): """Exercise authentication library creates valid credentials.""" @@ -84,6 +88,7 @@ async def test_auth(hass, aioclient_mock): assert creds.scopes == SDM_SCOPES +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth_expired_token(hass, aioclient_mock): """Verify behavior of an expired token.""" diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index 42b236fda7c..5c4194f46f6 100644 --- a/tests/components/nest/test_camera_sdm.py +++ b/tests/components/nest/test_camera_sdm.py @@ -158,6 +158,7 @@ async def mock_create_stream(hass) -> Mock: ) mock_stream.return_value.async_get_image = AsyncMock() mock_stream.return_value.async_get_image.return_value = IMAGE_BYTES_FROM_STREAM + mock_stream.return_value.start = AsyncMock() yield mock_stream @@ -370,6 +371,7 @@ async def test_refresh_expired_stream_token( # Request a stream for the camera entity to exercise nest cam + camera interaction # and shutdown on url expiration with patch("homeassistant.components.camera.create_stream") as create_stream: + create_stream.return_value.start = AsyncMock() hls_url = await camera.async_request_stream(hass, "camera.my_camera", fmt="hls") assert hls_url.startswith("/api/hls/") # Includes access token assert create_stream.called @@ -536,7 +538,8 @@ async def test_refresh_expired_stream_failure( # Request an HLS stream with patch("homeassistant.components.camera.create_stream") as create_stream: - + create_stream.return_value.start = AsyncMock() + create_stream.return_value.stop = AsyncMock() hls_url = await camera.async_request_stream(hass, "camera.my_camera", fmt="hls") assert hls_url.startswith("/api/hls/") # Includes access token assert create_stream.called @@ -555,6 +558,7 @@ async def test_refresh_expired_stream_failure( # Requesting an HLS stream will create an entirely new stream with patch("homeassistant.components.camera.create_stream") as create_stream: + create_stream.return_value.start = AsyncMock() # The HLS stream endpoint was invalidated, with a new auth token hls_url2 = await camera.async_request_stream( hass, "camera.my_camera", fmt="hls" diff --git a/tests/components/nest/test_config_flow_legacy.py b/tests/components/nest/test_config_flow_legacy.py index 843c9b582ae..f199d2ec7dd 100644 --- a/tests/components/nest/test_config_flow_legacy.py +++ b/tests/components/nest/test_config_flow_legacy.py @@ -13,15 +13,6 @@ from tests.common import MockConfigEntry CONFIG = TEST_CONFIG_LEGACY.config -async def test_abort_if_no_implementation_registered(hass): - """Test we abort if no implementation is registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" - - async def test_abort_if_single_instance_allowed(hass): """Test we abort if Nest is already setup.""" existing_entry = MockConfigEntry(domain=DOMAIN, data={}) diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index ab769d4b57c..b2ef95b138e 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -1,7 +1,9 @@ """Test the Google Nest Device Access config flow.""" -import copy -from unittest.mock import AsyncMock, patch +from __future__ import annotations + +from typing import Any +from unittest.mock import patch from google_nest_sdm.exceptions import ( AuthException, @@ -11,35 +13,35 @@ from google_nest_sdm.exceptions import ( from google_nest_sdm.structure import Structure import pytest -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components import dhcp +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from .common import FakeSubscriber, MockConfigEntry +from .common import ( + APP_AUTH_DOMAIN, + CLIENT_ID, + CLIENT_SECRET, + CLOUD_PROJECT_ID, + FAKE_TOKEN, + PROJECT_ID, + SUBSCRIBER_ID, + TEST_CONFIG_APP_CREDS, + TEST_CONFIG_HYBRID, + TEST_CONFIG_YAML_ONLY, + TEST_CONFIGFLOW_APP_CREDS, + TEST_CONFIGFLOW_YAML_ONLY, + WEB_AUTH_DOMAIN, + MockConfigEntry, + NestTestConfig, +) -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -PROJECT_ID = "project-id-4321" -SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" -CLOUD_PROJECT_ID = "cloud-id-9876" - -CONFIG = { - DOMAIN: { - "project_id": PROJECT_ID, - "subscriber_id": SUBSCRIBER_ID, - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - }, - "http": {"base_url": "https://example.com"}, -} - -ORIG_AUTH_DOMAIN = DOMAIN -WEB_AUTH_DOMAIN = DOMAIN -APP_AUTH_DOMAIN = f"{DOMAIN}.installed" WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" @@ -49,30 +51,6 @@ FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( ) -@pytest.fixture -def subscriber() -> FakeSubscriber: - """Create FakeSubscriber.""" - return FakeSubscriber() - - -def get_config_entry(hass): - """Return a single config entry.""" - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - return entries[0] - - -def create_config_entry(hass: HomeAssistant, data: dict) -> ConfigEntry: - """Create the ConfigEntry.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=data, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - return entry - - class OAuthFixture: """Simulate the oauth flow used by the config flow.""" @@ -82,17 +60,35 @@ class OAuthFixture: self.hass_client = hass_client_no_auth self.aioclient_mock = aioclient_mock - async def async_pick_flow(self, result: dict, auth_domain: str) -> dict: - """Invoke flow to puth the auth type to use for this flow.""" - assert result["type"] == "form" - assert result["step_id"] == "pick_implementation" + async def async_app_creds_flow( + self, + result: dict, + cloud_project_id: str = CLOUD_PROJECT_ID, + project_id: str = PROJECT_ID, + ) -> None: + """Invoke multiple steps in the app credentials based flow.""" + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" - return await self.async_configure(result, {"implementation": auth_domain}) + result = await self.async_configure( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" - async def async_oauth_web_flow(self, result: dict) -> None: + result = await self.async_configure(result, {"project_id": project_id}) + await self.async_oauth_web_flow(result, project_id=project_id) + + async def async_oauth_web_flow(self, result: dict, project_id=PROJECT_ID) -> None: """Invoke the oauth flow for Web Auth with fake responses.""" state = self.create_state(result, WEB_REDIRECT_URL) - assert result["url"] == self.authorize_url(state, WEB_REDIRECT_URL) + assert result["type"] == "external" + assert result["url"] == self.authorize_url( + state, + WEB_REDIRECT_URL, + CLIENT_ID, + project_id, + ) # Simulate user redirect back with auth code client = await self.hass_client() @@ -102,38 +98,26 @@ class OAuthFixture: await self.async_mock_refresh(result) - async def async_oauth_app_flow(self, result: dict) -> None: - """Invoke the oauth flow for Installed Auth with fake responses.""" - # Render form with a link to get an auth token - assert result["type"] == "form" - assert result["step_id"] == "auth" - assert "description_placeholders" in result - assert "url" in result["description_placeholders"] - state = self.create_state(result, APP_REDIRECT_URL) - assert result["description_placeholders"]["url"] == self.authorize_url( - state, APP_REDIRECT_URL - ) - # Simulate user entering auth token in form - await self.async_mock_refresh(result, {"code": "abcd"}) - - async def async_reauth(self, old_data: dict) -> dict: + async def async_reauth(self, config_entry: ConfigEntry) -> dict: """Initiate a reuath flow.""" - result = await self.hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_data - ) - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" + config_entry.async_start_reauth(self.hass) + await self.hass.async_block_till_done() # Advance through the reauth flow - flows = self.hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" + result = self.async_progress() + assert result["step_id"] == "reauth_confirm" # Advance to the oauth flow return await self.hass.config_entries.flow.async_configure( - flows[0]["flow_id"], {} + result["flow_id"], {} ) + def async_progress(self) -> FlowResult: + """Return the current step of the config flow.""" + flows = self.hass.config_entries.flow.async_progress() + assert len(flows) == 1 + return flows[0] + def create_state(self, result: dict, redirect_url: str) -> str: """Create state object based on redirect url.""" return config_entry_oauth2_flow._encode_jwt( @@ -144,11 +128,13 @@ class OAuthFixture: }, ) - def authorize_url(self, state: str, redirect_url: str) -> str: + def authorize_url( + self, state: str, redirect_url: str, client_id: str, project_id: str + ) -> str: """Generate the expected authorization url.""" - oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) + oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=project_id) return ( - f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" + f"{oauth_authorize}?response_type=code&client_id={client_id}" f"&redirect_uri={redirect_url}" f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" "+https://www.googleapis.com/auth/pubsub" @@ -179,13 +165,16 @@ class OAuthFixture: await self.hass.async_block_till_done() return self.get_config_entry() - async def async_configure(self, result: dict, user_input: dict) -> dict: + async def async_configure( + self, result: dict[str, Any], user_input: dict[str, Any] + ) -> dict: """Advance to the next step in the config flow.""" return await self.hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], + user_input, ) - async def async_pubsub_flow(self, result: dict, cloud_project_id="") -> ConfigEntry: + async def async_pubsub_flow(self, result: dict, cloud_project_id="") -> None: """Verify the pubsub creation step.""" # Render form with a link to get an auth token assert result["type"] == "form" @@ -196,7 +185,9 @@ class OAuthFixture: def get_config_entry(self) -> ConfigEntry: """Get the config entry.""" - return get_config_entry(self.hass) + entries = self.hass.config_entries.async_entries(DOMAIN) + assert len(entries) >= 1 + return entries[0] @pytest.fixture @@ -205,318 +196,176 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -async def async_setup_configflow(hass): - """Set up component so the pubsub subscriber is managed by config flow.""" - config = copy.deepcopy(CONFIG) - del config[DOMAIN]["subscriber_id"] # Create in config flow instead - return await setup.async_setup_component(hass, DOMAIN, config) - - -async def test_web_full_flow(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_app_credentials(hass, oauth, subscriber, setup_platform): """Check full flow.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await oauth.async_app_creds_flow(result) - result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) - - await oauth.async_oauth_web_flow(result) entry = await oauth.async_finish_setup(result) - assert entry.title == "OAuth for Web" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - # Subscriber from configuration.yaml - assert "subscriber_id" not in entry.data - -async def test_web_reauth(hass, oauth): - """Test Nest reauthentication.""" - - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) - - old_entry = create_config_entry( - hass, - { - "auth_implementation": WEB_AUTH_DOMAIN, - "token": { - # Verify this is replaced at end of the test - "access_token": "some-revoked-token", - }, - "sdm": {}, + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", }, - ) - - entry = get_config_entry(hass) - assert entry.data["token"] == { - "access_token": "some-revoked-token", } - result = await oauth.async_reauth(old_entry.data) - await oauth.async_oauth_web_flow(result) - entry = await oauth.async_finish_setup(result) - # Verify existing tokens are replaced - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN - assert "subscriber_id" not in entry.data # not updated - - -async def test_single_config_entry(hass): - """Test that only a single config entry is allowed.""" - create_config_entry(hass, {"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}}) - - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_restart(hass, oauth, subscriber, setup_platform): + """Check with auth implementation is re-initialized when aborting the flow.""" + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" - - -async def test_unexpected_existing_config_entries(hass, oauth): - """Test Nest reauthentication with multiple existing config entries.""" - # Note that this case will not happen in the future since only a single - # instance is now allowed, but this may have been allowed in the past. - # On reauth, only one entry is kept and the others are deleted. - - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) - - old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} - ) - old_entry.add_to_hass(hass) - - old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} - ) - old_entry.add_to_hass(hass) - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 2 - - # Invoke the reauth flow - result = await oauth.async_reauth(old_entry.data) - - await oauth.async_oauth_web_flow(result) - - await oauth.async_finish_setup(result) - - # Only a single entry now exists, and the other was cleaned up - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.unique_id == DOMAIN - entry.data["token"].pop("expires_at") - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert "subscriber_id" not in entry.data # not updated - - -async def test_reauth_missing_config_entry(hass): - """Test the reauth flow invoked missing existing data.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) - - # Invoke the reauth flow with no existing data - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=None - ) - assert result["type"] == "abort" - assert result["reason"] == "missing_configuration" - - -async def test_app_full_flow(hass, oauth): - """Check full flow.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await oauth.async_app_creds_flow(result) + # At this point, we should have a valid auth implementation configured. + # Simulate aborting the flow and starting over to ensure we get prompted + # again to configure everything. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" + + # Change the values to show they are reflected below + result = await oauth.async_configure( + result, {"cloud_project_id": "new-cloud-project-id"} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": "new-project-id"}) + await oauth.async_oauth_web_flow(result, "new-project-id") - await oauth.async_oauth_app_flow(result) entry = await oauth.async_finish_setup(result, {"code": "1234"}) - assert entry.title == "OAuth for Apps" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - # Subscriber from configuration.yaml - assert "subscriber_id" not in entry.data - -async def test_app_reauth(hass, oauth): - """Test Nest reauthentication for Installed App Auth.""" - - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) - - old_entry = create_config_entry( - hass, - { - "auth_implementation": APP_AUTH_DOMAIN, - "token": { - # Verify this is replaced at end of the test - "access_token": "some-revoked-token", - }, - "sdm": {}, + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert "projects/new-cloud-project-id/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": "new-cloud-project-id", + "project_id": "new-project-id", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", }, + } + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_wrong_project_id(hass, oauth, subscriber, setup_platform): + """Check the case where the wrong project ids are entered.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" - result = await oauth.async_reauth(old_entry.data) - await oauth.async_oauth_app_flow(result) + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + # Enter the cloud project id instead of device access project id (really we just check + # they are the same value which is never correct) + result = await oauth.async_configure(result, {"project_id": CLOUD_PROJECT_ID}) + assert result["type"] == "form" + assert "errors" in result + assert "project_id" in result["errors"] + assert result["errors"]["project_id"] == "wrong_project_id" + + # Fix with a correct value and complete the rest of the flow + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) + await oauth.async_oauth_web_flow(result) + await hass.async_block_till_done() - # Verify existing tokens are replaced entry = await oauth.async_finish_setup(result, {"code": "1234"}) - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, } - assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN - assert "subscriber_id" not in entry.data # not updated -async def test_pubsub_subscription(hass, oauth, subscriber): - """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_pubsub_configuration_error( + hass, + oauth, + setup_platform, + mock_subscriber, +): + """Check full flow fails with configuration error.""" + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + await oauth.async_app_creds_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() - - assert entry.title == "OAuth for Apps" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert "subscriber_id" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID - - -async def test_pubsub_subscription_strip_whitespace(hass, oauth, subscriber): - """Check that project id has whitespace stripped on entry.""" - assert await async_setup_configflow(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": " " + CLOUD_PROJECT_ID + " "} - ) - await hass.async_block_till_done() - - assert entry.title == "OAuth for Apps" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert "subscriber_id" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID - - -async def test_pubsub_subscription_auth_failure(hass, oauth): - """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + mock_subscriber.create_subscription.side_effect = ConfigurationException result = await oauth.async_configure(result, {"code": "1234"}) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", - side_effect=AuthException(), - ): - await oauth.async_pubsub_flow(result) - result = await oauth.async_configure( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "invalid_access_token" + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "bad_project_id" -async def test_pubsub_subscription_failure(hass, oauth): - """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_pubsub_subscriber_error( + hass, oauth, setup_platform, mock_subscriber +): + """Check full flow with a subscriber error.""" + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + await oauth.async_app_creds_flow(result) + + mock_subscriber.create_subscription.side_effect = SubscriberException() result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", - side_effect=SubscriberException(), - ): - result = await oauth.async_configure( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() assert result["type"] == "form" assert "errors" in result @@ -524,89 +373,304 @@ async def test_pubsub_subscription_failure(hass, oauth): assert result["errors"]["cloud_project_id"] == "subscriber_error" -async def test_pubsub_subscription_configuration_failure(hass, oauth): - """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +async def test_config_yaml_ignored(hass, oauth, setup_platform): + """Check full flow.""" + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", - side_effect=ConfigurationException(), - ): - result = await oauth.async_configure( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() - - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "bad_project_id" - - -async def test_pubsub_with_wrong_project_id(hass, oauth): - """Test a possible common misconfiguration mixing up project ids.""" - assert await async_setup_configflow(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - result = await oauth.async_configure( - result, {"cloud_project_id": PROJECT_ID} # SDM project id - ) await hass.async_block_till_done() - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "wrong_project_id" + assert result["step_id"] == "create_cloud_project" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" -async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): - """Test the pubsub subscriber id is preserved during reauth.""" - assert await async_setup_configflow(hass) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_YAML_ONLY]) +async def test_web_reauth(hass, oauth, setup_platform, config_entry): + """Test Nest reauthentication.""" + await setup_platform() - old_entry = create_config_entry( - hass, - { - "auth_implementation": APP_AUTH_DOMAIN, - "subscriber_id": SUBSCRIBER_ID, - "cloud_project_id": CLOUD_PROJECT_ID, - "token": { - "access_token": "some-revoked-token", - }, - "sdm": {}, - }, - ) - result = await oauth.async_reauth(old_entry.data) - await oauth.async_oauth_app_flow(result) + assert config_entry.data["token"].get("access_token") == FAKE_TOKEN - # Entering an updated access token refreshs the config entry. - entry = await oauth.async_finish_setup(result, {"code": "1234"}) + orig_subscriber_id = config_entry.data.get("subscriber_id") + result = await oauth.async_reauth(config_entry) + + await oauth.async_oauth_web_flow(result) + entry = await oauth.async_finish_setup(result) + # Verify existing tokens are replaced entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN + assert entry.unique_id == PROJECT_ID assert entry.data["token"] == { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, } - assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN + assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + + +async def test_multiple_config_entries(hass, oauth, setup_platform): + """Verify config flow can be started when existing config entry exists.""" + await setup_platform() + + 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} + ) + await oauth.async_app_creds_flow(result, project_id="project-id-2") + entry = await oauth.async_finish_setup(result) + assert entry.title == "Mock Title" + assert "token" in entry.data + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + +async def test_duplicate_config_entries(hass, oauth, setup_platform): + """Verify that config entries must be for unique projects.""" + await setup_platform() + + 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") == "form" + assert result.get("step_id") == "cloud_project" + + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) + assert result.get("type") == "abort" + assert result.get("reason") == "already_configured" + + +async def test_reauth_multiple_config_entries( + hass, oauth, setup_platform, config_entry +): + """Test Nest reauthentication with multiple existing config entries.""" + await setup_platform() + + old_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **config_entry.data, + "extra_data": True, + }, + ) + old_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + orig_subscriber_id = config_entry.data.get("subscriber_id") + + # Invoke the reauth flow + result = await oauth.async_reauth(config_entry) + + await oauth.async_oauth_web_flow(result) + + await oauth.async_finish_setup(result) + + # Only reauth entry was updated, the other entry is preserved + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + entry = entries[0] + assert entry.unique_id == PROJECT_ID + entry.data["token"].pop("expires_at") + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + assert not entry.data.get("extra_data") + + # Other entry was not refreshed + entry = entries[1] + entry.data["token"].pop("expires_at") + assert entry.data.get("token", {}).get("access_token") == "some-token" + assert entry.data.get("extra_data") + + +@pytest.mark.parametrize( + "nest_test_config,auth_implementation", [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] +) +async def test_app_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): + """Test reauth for deprecated app auth credentails upgrade instructions.""" + + await setup_platform() + + orig_subscriber_id = config_entry.data.get("subscriber_id") + assert config_entry.data["auth_implementation"] == APP_AUTH_DOMAIN + + result = oauth.async_progress() + assert result.get("step_id") == "reauth_confirm" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "form" + assert result.get("step_id") == "auth_upgrade" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" + await hass.async_block_till_done() + # Config flow is aborted, but new one created back in re-auth state waiting for user + # to create application credentials + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + # Emulate user entering credentials (different from configuration.yaml creds) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + # Config flow is placed back into a reuath state + result = oauth.async_progress() + assert result.get("step_id") == "reauth_confirm" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project_upgrade" + + # Frontend sends user back through the config flow again + result = await oauth.async_configure(result, {}) + await oauth.async_oauth_web_flow(result) + + # Verify existing tokens are replaced + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + entry.data["token"].pop("expires_at") + assert entry.unique_id == PROJECT_ID + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert entry.data["auth_implementation"] == DOMAIN + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + + # Existing entry is updated + assert config_entry.data["auth_implementation"] == DOMAIN + + +@pytest.mark.parametrize( + "nest_test_config,auth_implementation", [(TEST_CONFIG_YAML_ONLY, WEB_AUTH_DOMAIN)] +) +async def test_web_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): + """Test Nest reauthentication for Installed App Auth.""" + + await setup_platform() + + orig_subscriber_id = config_entry.data.get("subscriber_id") + + result = await oauth.async_reauth(config_entry) + await oauth.async_oauth_web_flow(result) + + # Verify existing tokens are replaced + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + entry.data["token"].pop("expires_at") + assert entry.unique_id == PROJECT_ID + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_pubsub_subscription_strip_whitespace( + hass, oauth, subscriber, setup_platform +): + """Check that project id has whitespace stripped on entry.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow( + result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " + ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + + assert entry.title == "Import from configuration.yaml" + assert "token" in entry.data + entry.data["token"].pop("expires_at") + assert entry.unique_id == PROJECT_ID + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert "subscriber_id" in entry.data + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_pubsub_subscription_auth_failure( + hass, oauth, setup_platform, mock_subscriber +): + """Check flow that creates a pub/sub subscription.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_subscriber.create_subscription.side_effect = AuthException() + + await oauth.async_app_creds_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) + + assert result["type"] == "abort" + assert result["reason"] == "invalid_access_token" + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) +async def test_pubsub_subscriber_config_entry_reauth( + hass, oauth, setup_platform, subscriber, config_entry, auth_implementation +): + """Test the pubsub subscriber id is preserved during reauth.""" + await setup_platform() + + result = await oauth.async_reauth(config_entry) + await oauth.async_oauth_web_flow(result) + + # Entering an updated access token refreshs the config entry. + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + entry.data["token"].pop("expires_at") + assert entry.unique_id == PROJECT_ID + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert entry.data["auth_implementation"] == auth_implementation assert entry.data["subscriber_id"] == SUBSCRIBER_ID assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_config_entry_title_from_home(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_entry_title_from_home(hass, oauth, setup_platform, subscriber): """Test that the Google Home name is used for the config entry title.""" device_manager = await subscriber.async_get_device_manager() @@ -623,32 +687,24 @@ async def test_config_entry_title_from_home(hass, oauth, subscriber): ) ) - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + await oauth.async_app_creds_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home" assert "token" in entry.data assert "subscriber_id" in entry.data assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_config_entry_title_multiple_homes(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_entry_title_multiple_homes( + hass, oauth, setup_platform, subscriber +): """Test handling of multiple Google Homes authorized.""" device_manager = await subscriber.async_get_device_manager() @@ -677,59 +733,37 @@ async def test_config_entry_title_multiple_homes(hass, oauth, subscriber): ) ) - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + await oauth.async_app_creds_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home #1, Example Home #2" -async def test_title_failure_fallback(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_title_failure_fallback(hass, oauth, setup_platform, mock_subscriber): """Test exception handling when determining the structure names.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + await oauth.async_app_creds_flow(result) - mock_subscriber = AsyncMock(FakeSubscriber) mock_subscriber.async_get_device_manager.side_effect = AuthException() - - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=mock_subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() - - assert entry.title == "OAuth for Apps" + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + assert entry.title == "Import from configuration.yaml" assert "token" in entry.data assert "subscriber_id" in entry.data assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_structure_missing_trait(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_structure_missing_trait(hass, oauth, setup_platform, subscriber): """Test handling the case where a structure has no name set.""" device_manager = await subscriber.async_get_device_manager() @@ -743,44 +777,39 @@ async def test_structure_missing_trait(hass, oauth, subscriber): ) ) - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + await oauth.async_app_creds_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) # Fallback to default name - assert entry.title == "OAuth for Apps" + assert entry.title == "Import from configuration.yaml" -async def test_dhcp_discovery_without_config(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [NestTestConfig()]) +async def test_dhcp_discovery(hass, oauth, subscriber): + """Exercise discovery dhcp starts the config flow and kicks user to frontend creds flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=FAKE_DHCP_DATA, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "create_cloud_project" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_dhcp_discovery_with_creds(hass, oauth, subscriber, setup_platform): """Exercise discovery dhcp with no config present (can't run).""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=FAKE_DHCP_DATA, - ) - await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == "missing_configuration" - - -async def test_dhcp_discovery(hass, oauth): - """Discover via dhcp when config is present.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, @@ -788,19 +817,33 @@ async def test_dhcp_discovery(hass, oauth): data=FAKE_DHCP_DATA, ) await hass.async_block_till_done() + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" - # DHCP discovery invokes the config flow - result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) await oauth.async_oauth_web_flow(result) - entry = await oauth.async_finish_setup(result) - assert entry.title == "OAuth for Web" - - # Discovery does not run once configured - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=FAKE_DHCP_DATA, - ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == "already_configured" + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index ee93323fcd8..3272c2a7c59 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -1,5 +1,4 @@ """The tests for Nest device triggers.""" -from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage import pytest @@ -10,11 +9,12 @@ from homeassistant.components.device_automation.exceptions import ( ) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.events import NEST_EVENT +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .common import async_setup_sdm_platform +from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup from tests.common import ( assert_lists_same, @@ -22,11 +22,16 @@ from tests.common import ( async_mock_service, ) -DEVICE_ID = "some-device-id" DEVICE_NAME = "My Camera" DATA_MESSAGE = {"message": "service-called"} +@pytest.fixture +def platforms() -> list[str]: + """Fixture to setup the platforms to test.""" + return ["camera"] + + def make_camera(device_id, name=DEVICE_NAME, traits={}): """Create a nest camera.""" traits = traits.copy() @@ -45,21 +50,11 @@ def make_camera(device_id, name=DEVICE_NAME, traits={}): }, } ) - return Device.MakeDevice( - { - "name": device_id, - "type": "sdm.devices.types.CAMERA", - "traits": traits, - }, - auth=None, - ) - - -async def async_setup_camera(hass, devices=None): - """Set up the platform and prerequisites for testing available triggers.""" - if not devices: - devices = {DEVICE_ID: make_camera(device_id=DEVICE_ID)} - return await async_setup_sdm_platform(hass, "camera", devices) + return { + "name": device_id, + "type": "sdm.devices.types.CAMERA", + "traits": traits, + } async def setup_automation(hass, device_id, trigger_type): @@ -92,16 +87,20 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_get_triggers(hass): +async def test_get_triggers( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test we get the expected triggers from a nest.""" - camera = make_camera( - device_id=DEVICE_ID, - traits={ - "sdm.devices.traits.CameraMotion": {}, - "sdm.devices.traits.CameraPerson": {}, - }, + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) ) - await async_setup_camera(hass, {DEVICE_ID: camera}) + await setup_platform() device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) @@ -128,23 +127,29 @@ async def test_get_triggers(hass): assert_lists_same(triggers, expected_triggers) -async def test_multiple_devices(hass): +async def test_multiple_devices( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test we get the expected triggers from a nest.""" - camera1 = make_camera( - device_id="device-id-1", - name="Camera 1", - traits={ - "sdm.devices.traits.CameraSound": {}, - }, + create_device.create( + raw_data=make_camera( + device_id="device-id-1", + name="Camera 1", + traits={ + "sdm.devices.traits.CameraSound": {}, + }, + ) ) - camera2 = make_camera( - device_id="device-id-2", - name="Camera 2", - traits={ - "sdm.devices.traits.DoorbellChime": {}, - }, + create_device.create( + raw_data=make_camera( + device_id="device-id-2", + name="Camera 2", + traits={ + "sdm.devices.traits.DoorbellChime": {}, + }, + ) ) - await async_setup_camera(hass, {"device-id-1": camera1, "device-id-2": camera2}) + await setup_platform() registry = er.async_get(hass) entry1 = registry.async_get("camera.camera_1") @@ -177,16 +182,20 @@ async def test_multiple_devices(hass): } -async def test_triggers_for_invalid_device_id(hass): +async def test_triggers_for_invalid_device_id( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Get triggers for a device not found in the API.""" - camera = make_camera( - device_id=DEVICE_ID, - traits={ - "sdm.devices.traits.CameraMotion": {}, - "sdm.devices.traits.CameraPerson": {}, - }, + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) ) - await async_setup_camera(hass, {DEVICE_ID: camera}) + await setup_platform() device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) @@ -207,14 +216,16 @@ async def test_triggers_for_invalid_device_id(hass): ) -async def test_no_triggers(hass): +async def test_no_triggers( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test we get the expected triggers from a nest.""" - camera = make_camera(device_id=DEVICE_ID, traits={}) - await async_setup_camera(hass, {DEVICE_ID: camera}) + create_device.create(raw_data=make_camera(device_id=DEVICE_ID, traits={})) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.my_camera") - assert entry.unique_id == "some-device-id-camera" + assert entry.unique_id == f"{DEVICE_ID}-camera" triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, entry.device_id @@ -294,15 +305,23 @@ async def test_trigger_for_wrong_event_type(hass, calls): assert len(calls) == 0 -async def test_subscriber_automation(hass, calls): +async def test_subscriber_automation( + hass: HomeAssistant, + calls: list, + create_device: CreateDevice, + setup_platform: PlatformSetup, + subscriber: FakeSubscriber, +) -> None: """Test end to end subscriber triggers automation.""" - camera = make_camera( - device_id=DEVICE_ID, - traits={ - "sdm.devices.traits.CameraMotion": {}, - }, + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + }, + ) ) - subscriber = await async_setup_camera(hass, {DEVICE_ID: camera}) + await setup_platform() device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index 8e28222e356..85b63b23301 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -127,8 +127,6 @@ async def test_setup_susbcriber_failure( ): """Test configuration error.""" with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch( "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", side_effect=SubscriberException(), ): diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 0ab387a7dea..83845586764 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -13,11 +13,12 @@ from unittest.mock import patch from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage +import pytest from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from .common import async_setup_sdm_platform +from .common import CreateDevice from tests.common import async_capture_events @@ -31,26 +32,43 @@ EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." EVENT_KEYS = {"device_id", "type", "timestamp", "zones"} +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms to setup.""" + return [PLATFORM] + + +@pytest.fixture +def device_type() -> str: + """Fixture for the type of device under test.""" + return "sdm.devices.types.DOORBELL" + + +@pytest.fixture +def device_traits() -> list[str]: + """Fixture for the present traits of the device under test.""" + return ["sdm.devices.traits.DoorbellChime"] + + +@pytest.fixture(autouse=True) +def device( + device_type: str, device_traits: dict[str, Any], create_device: CreateDevice +) -> None: + """Fixture to create a device under test.""" + return create_device.create( + raw_data={ + "name": DEVICE_ID, + "type": device_type, + "traits": create_device_traits(device_traits), + } + ) + + def event_view(d: Mapping[str, Any]) -> Mapping[str, Any]: """View of an event with relevant keys for testing.""" return {key: value for key, value in d.items() if key in EVENT_KEYS} -async def async_setup_devices(hass, device_type, traits={}, auth=None): - """Set up the platform and prerequisites.""" - devices = { - DEVICE_ID: Device.MakeDevice( - { - "name": DEVICE_ID, - "type": device_type, - "traits": traits, - }, - auth=auth, - ), - } - return await async_setup_sdm_platform(hass, PLATFORM, devices=devices) - - def create_device_traits(event_traits=[]): """Create fake traits for a device.""" result = { @@ -98,15 +116,45 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): ) -async def test_doorbell_chime_event(hass, auth): +@pytest.mark.parametrize( + "device_type,device_traits,event_trait,expected_model,expected_type", + [ + ( + "sdm.devices.types.DOORBELL", + ["sdm.devices.traits.DoorbellChime"], + "sdm.devices.events.DoorbellChime.Chime", + "Doorbell", + "doorbell_chime", + ), + ( + "sdm.devices.types.CAMERA", + ["sdm.devices.traits.CameraMotion"], + "sdm.devices.events.CameraMotion.Motion", + "Camera", + "camera_motion", + ), + ( + "sdm.devices.types.CAMERA", + ["sdm.devices.traits.CameraPerson"], + "sdm.devices.events.CameraPerson.Person", + "Camera", + "camera_person", + ), + ( + "sdm.devices.types.CAMERA", + ["sdm.devices.traits.CameraSound"], + "sdm.devices.events.CameraSound.Sound", + "Camera", + "camera_sound", + ), + ], +) +async def test_event( + hass, auth, setup_platform, subscriber, event_trait, expected_model, expected_type +): """Test a pubsub message for a doorbell event.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - auth, - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -118,115 +166,32 @@ async def test_doorbell_chime_event(hass, auth): device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "Front" - assert device.model == "Doorbell" + assert device.model == expected_model assert device.identifiers == {("nest", DEVICE_ID)} timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.DoorbellChime.Chime", timestamp=timestamp) - ) + await subscriber.async_receive_event(create_event(event_trait, timestamp=timestamp)) await hass.async_block_till_done() event_time = timestamp.replace(microsecond=0) assert len(events) == 1 assert event_view(events[0].data) == { "device_id": entry.device_id, - "type": "doorbell_chime", + "type": expected_type, "timestamp": event_time, } -async def test_camera_motion_event(hass): - """Test a pubsub message for a camera motion event.""" - events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.CAMERA", - create_device_traits(["sdm.devices.traits.CameraMotion"]), - ) - registry = er.async_get(hass) - entry = registry.async_get("camera.front") - assert entry is not None - - timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.CameraMotion.Motion", timestamp=timestamp) - ) - await hass.async_block_till_done() - - event_time = timestamp.replace(microsecond=0) - assert len(events) == 1 - assert event_view(events[0].data) == { - "device_id": entry.device_id, - "type": "camera_motion", - "timestamp": event_time, - } - - -async def test_camera_sound_event(hass): - """Test a pubsub message for a camera sound event.""" - events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.CAMERA", - create_device_traits(["sdm.devices.traits.CameraSound"]), - ) - registry = er.async_get(hass) - entry = registry.async_get("camera.front") - assert entry is not None - - timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.CameraSound.Sound", timestamp=timestamp) - ) - await hass.async_block_till_done() - - event_time = timestamp.replace(microsecond=0) - assert len(events) == 1 - assert event_view(events[0].data) == { - "device_id": entry.device_id, - "type": "camera_sound", - "timestamp": event_time, - } - - -async def test_camera_person_event(hass): +@pytest.mark.parametrize( + "device_traits", + [ + ["sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraPerson"], + ], +) +async def test_camera_multiple_event(hass, subscriber, setup_platform): """Test a pubsub message for a camera person event.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.CameraPerson"]), - ) - registry = er.async_get(hass) - entry = registry.async_get("camera.front") - assert entry is not None - - timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.CameraPerson.Person", timestamp=timestamp) - ) - await hass.async_block_till_done() - - event_time = timestamp.replace(microsecond=0) - assert len(events) == 1 - assert event_view(events[0].data) == { - "device_id": entry.device_id, - "type": "camera_person", - "timestamp": event_time, - } - - -async def test_camera_multiple_event(hass): - """Test a pubsub message for a camera person event.""" - events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits( - ["sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraPerson"] - ), - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None @@ -260,28 +225,20 @@ async def test_camera_multiple_event(hass): } -async def test_unknown_event(hass): +async def test_unknown_event(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() await subscriber.async_receive_event(create_event("some-event-id")) await hass.async_block_till_done() assert len(events) == 0 -async def test_unknown_device_id(hass): +async def test_unknown_device_id(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() await subscriber.async_receive_event( create_event("sdm.devices.events.DoorbellChime.Chime", "invalid-device-id") ) @@ -290,14 +247,10 @@ async def test_unknown_device_id(hass): assert len(events) == 0 -async def test_event_message_without_device_event(hass): +async def test_event_message_without_device_event(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() timestamp = utcnow() event = EventMessage( { @@ -312,20 +265,16 @@ async def test_event_message_without_device_event(hass): assert len(events) == 0 -async def test_doorbell_event_thread(hass, auth): +@pytest.mark.parametrize( + "device_traits", + [ + ["sdm.devices.traits.CameraClipPreview", "sdm.devices.traits.CameraPerson"], + ], +) +async def test_doorbell_event_thread(hass, subscriber, setup_platform): """Test a series of pubsub messages in the same thread.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits( - [ - "sdm.devices.traits.CameraClipPreview", - "sdm.devices.traits.CameraPerson", - ] - ), - auth, - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None @@ -381,21 +330,20 @@ async def test_doorbell_event_thread(hass, auth): } -async def test_doorbell_event_session_update(hass, auth): +@pytest.mark.parametrize( + "device_traits", + [ + [ + "sdm.devices.traits.CameraClipPreview", + "sdm.devices.traits.CameraPerson", + "sdm.devices.traits.CameraMotion", + ], + ], +) +async def test_doorbell_event_session_update(hass, subscriber, setup_platform): """Test a pubsub message with updates to an existing session.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits( - [ - "sdm.devices.traits.CameraClipPreview", - "sdm.devices.traits.CameraPerson", - "sdm.devices.traits.CameraMotion", - ] - ), - auth, - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None @@ -454,14 +402,10 @@ async def test_doorbell_event_session_update(hass, auth): } -async def test_structure_update_event(hass): +async def test_structure_update_event(hass, subscriber, setup_platform): """Test a pubsub message for a new device being added.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() # Entity for first device is registered registry = er.async_get(hass) @@ -499,9 +443,7 @@ async def test_structure_update_event(hass): }, auth=None, ) - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( + with patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( "homeassistant.components.nest.api.GoogleNestSubscriber", return_value=subscriber, ): @@ -516,14 +458,16 @@ async def test_structure_update_event(hass): assert not registry.async_get("camera.back") -async def test_event_zones(hass): +@pytest.mark.parametrize( + "device_traits", + [ + ["sdm.devices.traits.CameraMotion"], + ], +) +async def test_event_zones(hass, subscriber, setup_platform): """Test events published with zone information.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.CameraMotion"]), - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None diff --git a/tests/components/nest/test_init_legacy.py b/tests/components/nest/test_init_legacy.py index cbf1bfe2d48..fc4e6070faf 100644 --- a/tests/components/nest/test_init_legacy.py +++ b/tests/components/nest/test_init_legacy.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest -from .common import TEST_CONFIG_LEGACY +from .common import TEST_CONFIG_ENTRY_LEGACY, TEST_CONFIG_LEGACY DOMAIN = "nest" @@ -33,6 +33,9 @@ def make_thermostat(): return device +@pytest.mark.parametrize( + "nest_test_config", [TEST_CONFIG_LEGACY, TEST_CONFIG_ENTRY_LEGACY] +) async def test_thermostat(hass, setup_base_platform): """Test simple initialization for thermostat entities.""" diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index 381252c6f75..d7c82609c60 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -25,10 +25,13 @@ from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState from .common import ( + PROJECT_ID, + SUBSCRIBER_ID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_HYBRID, TEST_CONFIG_YAML_ONLY, + TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, - NestTestConfig, YieldFixture, ) @@ -100,18 +103,18 @@ async def test_setup_configuration_failure( @pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()]) async def test_setup_susbcriber_failure( - hass, error_caplog, failing_subscriber, setup_base_platform + hass, warning_caplog, failing_subscriber, setup_base_platform ): """Test configuration error.""" await setup_base_platform() - assert "Subscriber error:" in error_caplog.text + assert "Subscriber error:" in warning_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY -async def test_setup_device_manager_failure(hass, error_caplog, setup_base_platform): +async def test_setup_device_manager_failure(hass, warning_caplog, setup_base_platform): """Test device manager api failure.""" with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.start_async" @@ -121,8 +124,7 @@ async def test_setup_device_manager_failure(hass, error_caplog, setup_base_platf ): await setup_base_platform() - assert len(error_caplog.messages) == 1 - assert "Device manager error:" in error_caplog.text + assert "Device manager error:" in warning_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -170,7 +172,8 @@ async def test_subscriber_configuration_failure( @pytest.mark.parametrize( - "nest_test_config", [NestTestConfig(config={}, config_entry_data=None)] + "nest_test_config", + [TEST_CONFIGFLOW_APP_CREDS], ) async def test_empty_config(hass, error_caplog, config, setup_platform): """Test setup is a no-op with not config.""" @@ -205,8 +208,12 @@ async def test_unload_entry(hass, setup_platform): TEST_CONFIG_HYBRID, True, ), # Integration created subscriber, garbage collect on remove + ( + TEST_CONFIG_APP_CREDS, + True, + ), # Integration created subscriber, garbage collect on remove ], - ids=["yaml-config-only", "hybrid-config"], + ids=["yaml-config-only", "hybrid-config", "config-entry"], ) async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_called): """Test successful unload of a ConfigEntry.""" @@ -220,6 +227,9 @@ async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_ assert len(entries) == 1 entry = entries[0] assert entry.state is ConfigEntryState.LOADED + # Assert entry was imported if from configuration.yaml + assert entry.data.get("subscriber_id") == SUBSCRIBER_ID + assert entry.data.get("project_id") == PROJECT_ID with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.subscriber_id" @@ -234,7 +244,9 @@ async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_ @pytest.mark.parametrize( - "nest_test_config", [TEST_CONFIG_HYBRID], ids=["hyrbid-config"] + "nest_test_config", + [TEST_CONFIG_HYBRID, TEST_CONFIG_APP_CREDS], + ids=["hyrbid-config", "app-creds"], ) async def test_remove_entry_delete_subscriber_failure( hass, nest_test_config, setup_base_platform @@ -260,3 +272,18 @@ async def test_remove_entry_delete_subscriber_failure( entries = hass.config_entries.async_entries(DOMAIN) assert not entries + + +@pytest.mark.parametrize("config_entry_unique_id", [DOMAIN, None]) +async def test_migrate_unique_id( + hass, error_caplog, setup_platform, config_entry, config_entry_unique_id +): + """Test successful setup.""" + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry.unique_id == config_entry_unique_id + + await setup_platform() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == PROJECT_ID diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 09a3f9f625c..dff740c84f4 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -8,11 +8,11 @@ from collections.abc import Generator import datetime from http import HTTPStatus import io +from typing import Any from unittest.mock import patch import aiohttp import av -from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage import numpy as np import pytest @@ -27,17 +27,11 @@ from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import ( - CONFIG, - FakeSubscriber, - async_setup_sdm_platform, - create_config_entry, -) +from .common import DEVICE_ID, CreateDevice, FakeSubscriber from tests.common import async_capture_events DOMAIN = "nest" -DEVICE_ID = "example/api/device/id" DEVICE_NAME = "Front" PLATFORM = "camera" NEST_EVENT = "nest_event" @@ -90,10 +84,42 @@ def frame_image_data(frame_i, total_frames): return img +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms to setup.""" + return [PLATFORM] + + @pytest.fixture(autouse=True) -async def setup_media_source(hass) -> None: - """Set up media source.""" - assert await async_setup_component(hass, "media_source", {}) +async def setup_components(hass) -> None: + """Fixture to initialize the integration.""" + await async_setup_component(hass, "media_source", {}) + + +@pytest.fixture +def device_type() -> str: + """Fixture for the type of device under test.""" + return CAMERA_DEVICE_TYPE + + +@pytest.fixture +def device_traits() -> dict[str, Any]: + """Fixture for the present traits of the device under test.""" + return CAMERA_TRAITS + + +@pytest.fixture(autouse=True) +def device( + device_type: str, device_traits: dict[str, Any], create_device: CreateDevice +) -> None: + """Fixture to create a device under test.""" + return create_device.create( + raw_data={ + "name": DEVICE_ID, + "type": device_type, + "traits": device_traits, + } + ) @pytest.fixture @@ -128,22 +154,23 @@ def mp4() -> io.BytesIO: return output -async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): - """Set up the platform and prerequisites.""" - devices = { - DEVICE_ID: Device.MakeDevice( - { - "name": DEVICE_ID, - "type": device_type, - "traits": traits, - }, - auth=auth, - ), - } - subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) - # Enable feature for fetching media +@pytest.fixture(autouse=True) +def enable_prefetch(subscriber: FakeSubscriber) -> None: + """Fixture to enable media fetching for tests to exercise.""" subscriber.cache_policy.fetch = True - return subscriber + + +@pytest.fixture +def cache_size() -> int: + """Fixture for overrideing cache size.""" + return 100 + + +@pytest.fixture(autouse=True) +def apply_cache_size(cache_size): + """Fixture for patching the cache size.""" + with patch("homeassistant.components.nest.EVENT_MEDIA_CACHE_SIZE", new=cache_size): + yield def create_event( @@ -194,17 +221,20 @@ def create_battery_event_data( } -async def test_no_eligible_devices(hass, auth): +@pytest.mark.parametrize( + "device_type,device_traits", + [ + ( + "sdm.devices.types.THERMOSTAT", + { + "sdm.devices.traits.Temperature": {}, + }, + ) + ], +) +async def test_no_eligible_devices(hass, setup_platform): """Test a media source with no eligible camera devices.""" - await async_setup_devices( - hass, - auth, - "sdm.devices.types.THERMOSTAT", - { - "sdm.devices.traits.Temperature": {}, - }, - ) - + await setup_platform() browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN assert browse.identifier == "" @@ -212,10 +242,10 @@ async def test_no_eligible_devices(hass, auth): assert not browse.children -@pytest.mark.parametrize("traits", [CAMERA_TRAITS, BATTERY_CAMERA_TRAITS]) -async def test_supported_device(hass, auth, traits): +@pytest.mark.parametrize("device_traits", [CAMERA_TRAITS, BATTERY_CAMERA_TRAITS]) +async def test_supported_device(hass, setup_platform): """Test a media source with a supported camera.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, traits) + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -245,14 +275,9 @@ async def test_supported_device(hass, auth, traits): assert len(browse.children) == 0 -async def test_integration_unloaded(hass, auth): +async def test_integration_unloaded(hass, auth, setup_platform): """Test the media player loads, but has no devices, when config unloaded.""" - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - ) + await setup_platform() browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -276,11 +301,9 @@ async def test_integration_unloaded(hass, auth): assert len(browse.children) == 0 -async def test_camera_event(hass, auth, hass_client): +async def test_camera_event(hass, hass_client, subscriber, auth, setup_platform): """Test a media source and image created for an event.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -380,11 +403,9 @@ async def test_camera_event(hass, auth, hass_client): assert media.mime_type == "image/jpeg" -async def test_event_order(hass, auth): +async def test_event_order(hass, auth, subscriber, setup_platform): """Test multiple events are in descending timestamp order.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) + await setup_platform() auth.responses = [ aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), @@ -449,14 +470,15 @@ async def test_event_order(hass, auth): assert not browse.children[1].can_play -async def test_multiple_image_events_in_session(hass, auth, hass_client): +async def test_multiple_image_events_in_session( + hass, auth, hass_client, subscriber, setup_platform +): """Test multiple events published within the same event session.""" + await setup_platform() + event_session_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." event_timestamp1 = dt_util.now() event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -560,13 +582,19 @@ async def test_multiple_image_events_in_session(hass, auth, hass_client): assert contents == IMAGE_BYTES_FROM_EVENT + b"-1" -async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_multiple_clip_preview_events_in_session( + hass, + auth, + hass_client, + subscriber, + setup_platform, +): """Test multiple events published within the same event session.""" + await setup_platform() + event_timestamp1 = dt_util.now() event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -656,9 +684,9 @@ async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): assert contents == IMAGE_BYTES_FROM_EVENT -async def test_browse_invalid_device_id(hass, auth): +async def test_browse_invalid_device_id(hass, auth, setup_platform): """Test a media source request for an invalid device id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -676,9 +704,9 @@ async def test_browse_invalid_device_id(hass, auth): ) -async def test_browse_invalid_event_id(hass, auth): +async def test_browse_invalid_event_id(hass, auth, setup_platform): """Test a media source browsing for an invalid event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -699,9 +727,9 @@ async def test_browse_invalid_event_id(hass, auth): ) -async def test_resolve_missing_event_id(hass, auth): +async def test_resolve_missing_event_id(hass, auth, setup_platform): """Test a media source request missing an event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -716,10 +744,9 @@ async def test_resolve_missing_event_id(hass, auth): ) -async def test_resolve_invalid_device_id(hass, auth): +async def test_resolve_invalid_device_id(hass, auth, setup_platform): """Test resolving media for an invalid event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() with pytest.raises(Unresolvable): await media_source.async_resolve_media( hass, @@ -728,9 +755,9 @@ async def test_resolve_invalid_device_id(hass, auth): ) -async def test_resolve_invalid_event_id(hass, auth): +async def test_resolve_invalid_event_id(hass, auth, setup_platform): """Test resolving media for an invalid event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -750,14 +777,14 @@ async def test_resolve_invalid_event_id(hass, auth): assert media.mime_type == "image/jpeg" -async def test_camera_event_clip_preview(hass, auth, hass_client, mp4): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_camera_event_clip_preview( + hass, auth, hass_client, mp4, subscriber, setup_platform +): """Test an event for a battery camera video clip.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) - # Capture any events published received_events = async_capture_events(hass, NEST_EVENT) + await setup_platform() auth.responses = [ aiohttp.web.Response(body=mp4.getvalue()), @@ -857,10 +884,11 @@ async def test_camera_event_clip_preview(hass, auth, hass_client, mp4): await response.read() # Animated gif format not tested -async def test_event_media_render_invalid_device_id(hass, auth, hass_client): +async def test_event_media_render_invalid_device_id( + hass, auth, hass_client, setup_platform +): """Test event media API called with an invalid device id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() client = await hass_client() response = await client.get("/api/nest/event_media/invalid-device-id") assert response.status == HTTPStatus.NOT_FOUND, ( @@ -868,10 +896,11 @@ async def test_event_media_render_invalid_device_id(hass, auth, hass_client): ) -async def test_event_media_render_invalid_event_id(hass, auth, hass_client): +async def test_event_media_render_invalid_event_id( + hass, auth, hass_client, setup_platform +): """Test event media API called with an invalid device id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) assert device @@ -884,13 +913,11 @@ async def test_event_media_render_invalid_event_id(hass, auth, hass_client): ) -async def test_event_media_failure(hass, auth, hass_client): +async def test_event_media_failure(hass, auth, hass_client, subscriber, setup_platform): """Test event media fetch sees a failure from the server.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) received_events = async_capture_events(hass, NEST_EVENT) + await setup_platform() # Failure from server when fetching media auth.responses = [ aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), @@ -937,10 +964,11 @@ async def test_event_media_failure(hass, auth, hass_client): ) -async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin_user): +async def test_media_permission_unauthorized( + hass, auth, hass_client, hass_admin_user, setup_platform +): """Test case where user does not have permissions to view media.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") assert camera is not None @@ -962,33 +990,22 @@ async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin ) -async def test_multiple_devices(hass, auth, hass_client): +async def test_multiple_devices( + hass, auth, hass_client, create_device, subscriber, setup_platform +): """Test events received for multiple devices.""" - device_id1 = f"{DEVICE_ID}-1" device_id2 = f"{DEVICE_ID}-2" - - devices = { - device_id1: Device.MakeDevice( - { - "name": device_id1, - "type": CAMERA_DEVICE_TYPE, - "traits": CAMERA_TRAITS, - }, - auth=auth, - ), - device_id2: Device.MakeDevice( - { - "name": device_id2, - "type": CAMERA_DEVICE_TYPE, - "traits": CAMERA_TRAITS, - }, - auth=auth, - ), - } - subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) + create_device.create( + raw_data={ + "name": device_id2, + "type": CAMERA_DEVICE_TYPE, + "traits": CAMERA_TRAITS, + } + ) + await setup_platform() device_registry = dr.async_get(hass) - device1 = device_registry.async_get_device({(DOMAIN, device_id1)}) + device1 = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) assert device1 device2 = device_registry.async_get_device({(DOMAIN, device_id2)}) assert device2 @@ -1018,7 +1035,7 @@ async def test_multiple_devices(hass, auth, hass_client): f"event-session-id-{i}", f"event-id-{i}", PERSON_EVENT, - device_id=device_id1, + device_id=DEVICE_ID, ) ) await hass.async_block_till_done() @@ -1073,34 +1090,18 @@ def event_store() -> Generator[None, None, None]: yield -async def test_media_store_persistence(hass, auth, hass_client, event_store): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_media_store_persistence( + hass, + auth, + hass_client, + event_store, + subscriber, + setup_platform, + config_entry, +): """Test the disk backed media store persistence.""" - nest_device = Device.MakeDevice( - { - "name": DEVICE_ID, - "type": CAMERA_DEVICE_TYPE, - "traits": BATTERY_CAMERA_TRAITS, - }, - auth=auth, - ) - - subscriber = FakeSubscriber() - device_manager = await subscriber.async_get_device_manager() - device_manager.add_device(nest_device) - # Fetch media for events when published - subscriber.cache_policy.fetch = True - - config_entry = create_config_entry() - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -1154,18 +1155,8 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): # Now rebuild the entire integration and verify that all persisted storage # can be re-loaded from disk. - subscriber = FakeSubscriber() - device_manager = await subscriber.async_get_device_manager() - device_manager.add_device(nest_device) - - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -1197,11 +1188,12 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): assert contents == IMAGE_BYTES_FROM_EVENT -async def test_media_store_save_filesystem_error(hass, auth, hass_client): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_media_store_save_filesystem_error( + hass, auth, hass_client, subscriber, setup_platform +): """Test a filesystem error writing event media.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) + await setup_platform() auth.responses = [ aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), @@ -1250,11 +1242,11 @@ async def test_media_store_save_filesystem_error(hass, auth, hass_client): ) -async def test_media_store_load_filesystem_error(hass, auth, hass_client): +async def test_media_store_load_filesystem_error( + hass, auth, hass_client, subscriber, setup_platform +): """Test a filesystem error reading event media.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -1299,17 +1291,12 @@ async def test_media_store_load_filesystem_error(hass, auth, hass_client): ) -async def test_camera_event_media_eviction(hass, auth, hass_client): +@pytest.mark.parametrize("device_traits,cache_size", [(BATTERY_CAMERA_TRAITS, 5)]) +async def test_camera_event_media_eviction( + hass, auth, hass_client, subscriber, setup_platform +): """Test media files getting evicted from the cache.""" - - # Set small cache size for testing eviction - with patch("homeassistant.components.nest.EVENT_MEDIA_CACHE_SIZE", new=5): - subscriber = await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - BATTERY_CAMERA_TRAITS, - ) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -1384,23 +1371,9 @@ async def test_camera_event_media_eviction(hass, auth, hass_client): await hass.async_block_till_done() -async def test_camera_image_resize(hass, auth, hass_client): +async def test_camera_image_resize(hass, auth, hass_client, subscriber, setup_platform): """Test scaling a thumbnail for an event image.""" - event_timestamp = dt_util.now() - subscriber = await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], - ) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py new file mode 100644 index 00000000000..667c03a23cf --- /dev/null +++ b/tests/components/nexia/test_init.py @@ -0,0 +1,60 @@ +"""The init tests for the nexia platform.""" + + +from homeassistant.components.nexia.const import DOMAIN +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component + +from .util import async_init_integration + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + await async_setup_component(hass, "config", {}) + config_entry = await async_init_integration(hass) + entry_id = config_entry.entry_id + device_registry = dr.async_get(hass) + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["sensor.nick_office_temperature"] + + live_zone_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), live_zone_device_entry.id, entry_id + ) + is False + ) + + entity = registry.entities["sensor.master_suite_relative_humidity"] + live_thermostat_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), live_thermostat_device_entry.id, entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "unused")}, + ) + assert ( + await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) + is True + ) diff --git a/tests/components/nfandroidtv/test_config_flow.py b/tests/components/nfandroidtv/test_config_flow.py index b16b053c70f..a8b7b5fef53 100644 --- a/tests/components/nfandroidtv/test_config_flow.py +++ b/tests/components/nfandroidtv/test_config_flow.py @@ -4,8 +4,7 @@ from unittest.mock import patch from notifications_android_tv.notifications import ConnectError from homeassistant import config_entries, data_entry_flow -from homeassistant.components.nfandroidtv.const import DEFAULT_NAME, DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.components.nfandroidtv.const import DOMAIN from . import ( CONF_CONFIG_FLOW, @@ -95,41 +94,3 @@ async def test_flow_user_unknown_error(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} - - -async def test_flow_import(hass): - """Test an import flow.""" - mocked_tv = await _create_mocked_tv(True) - with _patch_config_flow_tv(mocked_tv), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONF_CONFIG_FLOW, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == CONF_DATA - - with _patch_config_flow_tv(mocked_tv), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONF_CONFIG_FLOW, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_flow_import_missing_optional(hass): - """Test an import flow with missing options.""" - mocked_tv = await _create_mocked_tv(True) - with _patch_config_flow_tv(mocked_tv), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == {CONF_HOST: HOST, CONF_NAME: f"{DEFAULT_NAME} {HOST}"} diff --git a/tests/components/nina/__init__.py b/tests/components/nina/__init__.py index d6c9fffdfa7..da09b0ba17b 100644 --- a/tests/components/nina/__init__.py +++ b/tests/components/nina/__init__.py @@ -15,9 +15,19 @@ def mocked_request_function(url: str) -> dict[str, Any]: load_fixture("sample_warning_details.json", "nina") ) - if url == "https://warnung.bund.de/api31/dashboard/083350000000.json": + dummy_response_regions: dict[str, Any] = json.loads( + load_fixture("sample_regions.json", "nina") + ) + + if "https://warnung.bund.de/api31/dashboard/" in url: return dummy_response + if ( + url + == "https://www.xrepository.de/api/xrepository/urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31/download/Regionalschl_ssel_2021-07-31.json" + ): + return dummy_response_regions + warning_id = url.replace("https://warnung.bund.de/api31/warnings/", "").replace( ".json", "" ) diff --git a/tests/components/nina/fixtures/sample_regions.json b/tests/components/nina/fixtures/sample_regions.json index 4fbc0638604..140feb39c3b 100644 --- a/tests/components/nina/fixtures/sample_regions.json +++ b/tests/components/nina/fixtures/sample_regions.json @@ -3,14 +3,28 @@ "kennung": "urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31", "kennungInhalt": "urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs", "version": "2021-07-31", - "nameKurz": "Regionalschlüssel", - "nameLang": "Gemeinden, dargestellt durch den Amtlichen Regionalschlüssel (ARS) des Statistischen Bundesamtes", + "nameKurz": [{ "value": "Regionalschlüssel", "lang": null }], + "nameLang": [ + { + "value": "Gemeinden, dargestellt durch den Amtlichen Regionalschlüssel (ARS) des Statistischen Bundesamtes", + "lang": null + } + ], "nameTechnisch": "Regionalschluessel", - "herausgebernameLang": "Statistisches Bundesamt, Wiesbaden", - "herausgebernameKurz": "Destatis", - "beschreibung": "Diese Codeliste stellt alle Gemeinden Deutschlands durch den Amtlichen Regionalschlüssel (ARS) dar, wie im Gemeindeverzeichnis des Statistischen Bundesamtes enthalten. Darüber hinaus enthält die Codeliste für die Stadtstaaten Hamburg, Bremen und Berlin Einträge für Stadt-/Ortsteile bzw. Stadtbezirke. Diese Einträge sind mit einem entsprechenden Hinweis versehen.", - "versionBeschreibung": null, - "aenderungZurVorversion": "Mehrere Aenderungen", + "herausgebernameLang": [ + { "value": "Statistisches Bundesamt, Wiesbaden", "lang": null } + ], + "herausgebernameKurz": [{ "value": "Destatis", "lang": null }], + "beschreibung": [ + { + "value": "Diese Codeliste stellt alle Gemeinden Deutschlands durch den Amtlichen Regionalschlüssel (ARS) dar, wie im Gemeindeverzeichnis des Statistischen Bundesamtes enthalten. Darüber hinaus enthält die Codeliste für die Stadtstaaten Hamburg, Bremen und Berlin Einträge für Stadt-/Ortsteile bzw. Stadtbezirke. Diese Einträge sind mit einem entsprechenden Hinweis versehen.", + "lang": null + } + ], + "versionBeschreibung": [], + "aenderungZurVorversion": [ + { "value": "Mehrere Aenderungen", "lang": null } + ], "handbuchVersion": "1.0", "xoevHandbuch": false, "gueltigAb": 1627682400000, @@ -23,7 +37,8 @@ "datentyp": "string", "codeSpalte": true, "verwendung": { "code": "REQUIRED" }, - "empfohleneCodeSpalte": true + "empfohleneCodeSpalte": true, + "sprache": null }, { "spaltennameLang": "Bezeichnung", @@ -31,7 +46,8 @@ "datentyp": "string", "codeSpalte": false, "verwendung": { "code": "REQUIRED" }, - "empfohleneCodeSpalte": false + "empfohleneCodeSpalte": false, + "sprache": null }, { "spaltennameLang": "Hinweis", @@ -39,7 +55,8 @@ "datentyp": "string", "codeSpalte": false, "verwendung": { "code": "OPTIONAL" }, - "empfohleneCodeSpalte": false + "empfohleneCodeSpalte": false, + "sprache": null } ], "daten": [ diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index a1aa97e0fbe..1578991ba11 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant import data_entry_flow from homeassistant.components.nina.const import ( CONF_FILTER_CORONA, CONF_MESSAGE_SLOTS, + CONF_REGIONS, CONST_REGION_A_TO_D, CONST_REGION_E_TO_H, CONST_REGION_I_TO_L, @@ -21,8 +22,11 @@ from homeassistant.components.nina.const import ( ) from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import load_fixture +from . import mocked_request_function + +from tests.common import MockConfigEntry, load_fixture DUMMY_DATA: dict[str, Any] = { CONF_MESSAGE_SLOTS: 5, @@ -35,14 +39,19 @@ DUMMY_DATA: dict[str, Any] = { CONF_FILTER_CORONA: True, } -DUMMY_RESPONSE: dict[str, Any] = json.loads(load_fixture("sample_regions.json", "nina")) +DUMMY_RESPONSE_REGIONS: dict[str, Any] = json.loads( + load_fixture("sample_regions.json", "nina") +) +DUMMY_RESPONSE_WARNIGNS: dict[str, Any] = json.loads( + load_fixture("sample_warnings.json", "nina") +) async def test_show_set_form(hass: HomeAssistant) -> None: """Test that the setup form is served.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -86,7 +95,7 @@ async def test_step_user(hass: HomeAssistant) -> None: """Test starting a flow by user with valid values.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ), patch( "homeassistant.components.nina.async_setup_entry", return_value=True, @@ -104,7 +113,7 @@ async def test_step_user_no_selection(hass: HomeAssistant) -> None: """Test starting a flow by user with no selection.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -120,7 +129,7 @@ async def test_step_user_already_configured(hass: HomeAssistant) -> None: """Test starting a flow by user but it was already configured.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA @@ -132,3 +141,176 @@ async def test_step_user_already_configured(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow_init(hass: HomeAssistant) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data={ + CONF_FILTER_CORONA: DUMMY_DATA[CONF_FILTER_CORONA], + CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS], + CONST_REGION_A_TO_D: DUMMY_DATA[CONST_REGION_A_TO_D], + CONF_REGIONS: {"095760000000": "Aach"}, + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nina.async_setup_entry", return_value=True + ), patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONST_REGION_A_TO_D: ["072350000000_1"], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] is None + + assert dict(config_entry.data) == { + CONF_FILTER_CORONA: DUMMY_DATA[CONF_FILTER_CORONA], + CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS], + CONST_REGION_A_TO_D: ["072350000000_1"], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + CONF_REGIONS: { + "072350000000": "Damflos (Trier-Saarburg - Rheinland-Pfalz)" + }, + } + + +async def test_options_flow_with_no_selection(hass: HomeAssistant) -> None: + """Test config flow options with no selection.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nina.async_setup_entry", return_value=True + ), patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONST_REGION_A_TO_D: [], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "no_selection"} + + +async def test_options_flow_connection_error(hass: HomeAssistant) -> None: + """Test config flow options but no connection.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=ApiError("Could not connect to Api"), + ): + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: + """Test config flow options but with an unexpected exception.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=Exception("DUMMY"), + ): + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_options_flow_entity_removal(hass: HomeAssistant) -> None: + """Test if old entities are removed.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MESSAGE_SLOTS: 2, + CONST_REGION_A_TO_D: ["072350000000", "095760000000"], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + entity_registry: er = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + assert len(entries) == 2 diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 8fdf03a7d7b..9921d2a639e 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -4,19 +4,141 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, ATTR_STEP, ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE, + NumberDeviceClass, NumberEntity, + NumberEntityDescription, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + CONF_PLATFORM, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM + +from tests.common import mock_restore_cache_with_extra_data class MockDefaultNumberEntity(NumberEntity): - """Mock NumberEntity device to use in tests.""" + """Mock NumberEntity device to use in tests. + + This class falls back on defaults for min_value, max_value, step. + """ + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntity(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value as overridden methods. + Step is calculated based on the smaller max_value and min_value. + """ + + @property + def native_max_value(self) -> float: + """Return the max value.""" + return 0.5 + + @property + def native_min_value(self) -> float: + """Return the min value.""" + return -0.5 + + @property + def native_unit_of_measurement(self): + """Return the current value.""" + return "native_cats" + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntityAttr(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by setting _attr members. + Step is calculated based on the smaller max_value and min_value. + """ + + _attr_native_max_value = 1000.0 + _attr_native_min_value = -1000.0 + _attr_native_step = 100.0 + _attr_native_unit_of_measurement = "native_dogs" + _attr_native_value = 500.0 + + +class MockNumberEntityDescr(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by entity description. + Step is calculated based on the smaller max_value and min_value. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + native_max_value=10.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement="native_rabbits", + ) + + @property + def native_value(self): + """Return the current value.""" + return None + + +class MockDefaultNumberEntityDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class falls back on defaults for min_value, max_value, step. + """ + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntityDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value as overridden methods. + Step is calculated based on the smaller max_value and min_value. + """ + + @property + def max_value(self) -> float: + """Return the max value.""" + return 0.5 + + @property + def min_value(self) -> float: + """Return the min value.""" + return -0.5 + + @property + def unit_of_measurement(self): + """Return the current value.""" + return "cats" @property def value(self): @@ -24,13 +146,36 @@ class MockDefaultNumberEntity(NumberEntity): return 0.5 -class MockNumberEntity(NumberEntity): - """Mock NumberEntity device to use in tests.""" +class MockNumberEntityAttrDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. - @property - def max_value(self) -> float: - """Return the max value.""" - return 1.0 + This class customizes min_value, max_value by setting _attr members. + Step is calculated based on the smaller max_value and min_value. + """ + + _attr_max_value = 1000.0 + _attr_min_value = -1000.0 + _attr_step = 100.0 + _attr_unit_of_measurement = "dogs" + _attr_value = 500.0 + + +class MockNumberEntityDescrDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by entity description. + Step is calculated based on the smaller max_value and min_value. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + max_value=10.0, + min_value=-10.0, + step=2.0, + unit_of_measurement="rabbits", + ) @property def value(self): @@ -41,12 +186,101 @@ class MockNumberEntity(NumberEntity): async def test_step(hass: HomeAssistant) -> None: """Test the step calculation.""" number = MockDefaultNumberEntity() + number.hass = hass assert number.step == 1.0 number_2 = MockNumberEntity() + number_2.hass = hass assert number_2.step == 0.1 +async def test_attributes(hass: HomeAssistant) -> None: + """Test the attributes.""" + number = MockDefaultNumberEntity() + number.hass = hass + assert number.max_value == 100.0 + assert number.min_value == 0.0 + assert number.step == 1.0 + assert number.unit_of_measurement is None + assert number.value == 0.5 + + number_2 = MockNumberEntity() + number_2.hass = hass + assert number_2.max_value == 0.5 + assert number_2.min_value == -0.5 + assert number_2.step == 0.1 + assert number_2.unit_of_measurement == "native_cats" + assert number_2.value == 0.5 + + number_3 = MockNumberEntityAttr() + number_3.hass = hass + assert number_3.max_value == 1000.0 + assert number_3.min_value == -1000.0 + assert number_3.step == 100.0 + assert number_3.unit_of_measurement == "native_dogs" + assert number_3.value == 500.0 + + number_4 = MockNumberEntityDescr() + number_4.hass = hass + assert number_4.max_value == 10.0 + assert number_4.min_value == -10.0 + assert number_4.step == 2.0 + assert number_4.unit_of_measurement == "native_rabbits" + assert number_4.value is None + + +async def test_deprecation_warnings(hass: HomeAssistant, caplog) -> None: + """Test overriding the deprecated attributes is possible and warnings are logged.""" + number = MockDefaultNumberEntityDeprecated() + number.hass = hass + assert number.max_value == 100.0 + assert number.min_value == 0.0 + assert number.step == 1.0 + assert number.unit_of_measurement is None + assert number.value == 0.5 + + number_2 = MockNumberEntityDeprecated() + number_2.hass = hass + assert number_2.max_value == 0.5 + assert number_2.min_value == -0.5 + assert number_2.step == 0.1 + assert number_2.unit_of_measurement == "cats" + assert number_2.value == 0.5 + + number_3 = MockNumberEntityAttrDeprecated() + number_3.hass = hass + assert number_3.max_value == 1000.0 + assert number_3.min_value == -1000.0 + assert number_3.step == 100.0 + assert number_3.unit_of_measurement == "dogs" + assert number_3.value == 500.0 + + number_4 = MockNumberEntityDescrDeprecated() + number_4.hass = hass + assert number_4.max_value == 10.0 + assert number_4.min_value == -10.0 + assert number_4.step == 2.0 + assert number_4.unit_of_measurement == "rabbits" + assert number_4.value == 0.5 + + assert ( + "tests.components.number.test_init::MockNumberEntityDeprecated is overriding " + " deprecated methods on an instance of NumberEntity" + ) + assert ( + "Entity None () " + "is using deprecated NumberEntity features" in caplog.text + ) + assert ( + "Entity None () " + "is using deprecated NumberEntity features" in caplog.text + ) + assert ( + "tests.components.number.test_init is setting deprecated attributes on an " + "instance of NumberEntityDescription" in caplog.text + ) + + async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" number = MockDefaultNumberEntity() @@ -59,9 +293,7 @@ async def test_sync_set_value(hass: HomeAssistant) -> None: assert number.set_value.call_args[0][0] == 42 -async def test_custom_integration_and_validation( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +async def test_set_value(hass: HomeAssistant, enable_custom_integrations: None) -> None: """Test we can only set valid values.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() @@ -79,9 +311,8 @@ async def test_custom_integration_and_validation( {ATTR_VALUE: 60.0, ATTR_ENTITY_ID: "number.test"}, blocking=True, ) - - hass.states.async_set("number.test", 60.0) await hass.async_block_till_done() + state = hass.states.get("number.test") assert state.state == "60.0" @@ -97,3 +328,364 @@ async def test_custom_integration_and_validation( await hass.async_block_till_done() state = hass.states.get("number.test") assert state.state == "60.0" + + +async def test_deprecated_attributes( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test entity using deprecated attributes.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append(platform.LegacyMockNumberEntity()) + entity = platform.ENTITIES[0] + entity._attr_name = "Test" + entity._attr_max_value = 25 + entity._attr_min_value = -25 + entity._attr_step = 2.5 + entity._attr_value = 51.0 + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "51.0" + assert state.attributes.get(ATTR_MAX) == 25.0 + assert state.attributes.get(ATTR_MIN) == -25.0 + assert state.attributes.get(ATTR_STEP) == 2.5 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "0.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "0.0" + + +async def test_deprecated_methods( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test entity using deprecated methods.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append( + platform.LegacyMockNumberEntity( + name="Test", + max_value=25.0, + min_value=-25.0, + step=2.5, + value=51.0, + ) + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "51.0" + assert state.attributes.get(ATTR_MAX) == 25.0 + assert state.attributes.get(ATTR_MIN) == -25.0 + assert state.attributes.get(ATTR_STEP) == 2.5 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "0.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "0.0" + + +@pytest.mark.parametrize( + "unit_system, native_unit, state_unit, initial_native_value, initial_state_value, " + "updated_native_value, updated_state_value, native_max_value, state_max_value, " + "native_min_value, state_min_value, native_step, state_step", + [ + ( + IMPERIAL_SYSTEM, + TEMP_FAHRENHEIT, + TEMP_FAHRENHEIT, + 100, + 100, + 50, + 50, + 140, + 140, + -9, + -9, + 3, + 3, + ), + ( + IMPERIAL_SYSTEM, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + 38, + 100, + 10, + 50, + 60, + 140, + -23, + -10, + 3, + 3, + ), + ( + METRIC_SYSTEM, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + 100, + 38, + 50, + 10, + 140, + 60, + -9, + -23, + 3, + 3, + ), + ( + METRIC_SYSTEM, + TEMP_CELSIUS, + TEMP_CELSIUS, + 38, + 38, + 10, + 10, + 60, + 60, + -23, + -23, + 3, + 3, + ), + ], +) +async def test_temperature_conversion( + hass, + enable_custom_integrations, + unit_system, + native_unit, + state_unit, + initial_native_value, + initial_state_value, + updated_native_value, + updated_state_value, + native_max_value, + state_max_value, + native_min_value, + state_min_value, + native_step, + state_step, +): + """Test temperature conversion.""" + hass.config.units = unit_system + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockNumberEntity( + name="Test", + native_max_value=native_max_value, + native_min_value=native_min_value, + native_step=native_step, + native_unit_of_measurement=native_unit, + native_value=initial_native_value, + device_class=NumberDeviceClass.TEMPERATURE, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(initial_state_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit + assert state.attributes[ATTR_MAX] == state_max_value + assert state.attributes[ATTR_MIN] == state_min_value + assert state.attributes[ATTR_STEP] == state_step + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: updated_state_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(updated_state_value)) + assert entity0._values["native_value"] == updated_native_value + + # Set to the minimum value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: state_min_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(state_min_value), rel=0.1) + + # Set to the maximum value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: state_max_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(state_max_value), rel=0.1) + + +RESTORE_DATA = { + "native_max_value": 200.0, + "native_min_value": -10.0, + "native_step": 2.0, + "native_unit_of_measurement": "°F", + "native_value": 123.0, +} + + +async def test_restore_number_save_state( + hass, + hass_storage, + enable_custom_integrations, +): + """Test RestoreNumber.""" + platform = getattr(hass.components, "test.number") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockRestoreNumber( + name="Test", + native_max_value=200.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement=TEMP_FAHRENHEIT, + native_value=123.0, + device_class=NumberDeviceClass.TEMPERATURE, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + await hass.async_block_till_done() + + # Trigger saving state + await hass.async_stop() + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity0.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == RESTORE_DATA + assert type(extra_data["native_value"]) == float + + +@pytest.mark.parametrize( + "native_max_value, native_min_value, native_step, native_value, native_value_type, extra_data, device_class, uom", + [ + ( + 200.0, + -10.0, + 2.0, + 123.0, + float, + RESTORE_DATA, + NumberDeviceClass.TEMPERATURE, + "°F", + ), + (100.0, 0.0, None, None, type(None), None, None, None), + (100.0, 0.0, None, None, type(None), {}, None, None), + (100.0, 0.0, None, None, type(None), {"beer": 123}, None, None), + ( + 100.0, + 0.0, + None, + None, + type(None), + {"native_unit_of_measurement": "°F", "native_value": {}}, + None, + None, + ), + ], +) +async def test_restore_number_restore_state( + hass, + enable_custom_integrations, + hass_storage, + native_max_value, + native_min_value, + native_step, + native_value, + native_value_type, + extra_data, + device_class, + uom, +): + """Test RestoreNumber.""" + mock_restore_cache_with_extra_data(hass, ((State("number.test", ""), extra_data),)) + + platform = getattr(hass.components, "test.number") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockRestoreNumber( + device_class=device_class, + name="Test", + native_value=None, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + await hass.async_block_till_done() + + assert hass.states.get(entity0.entity_id) + + assert entity0.native_max_value == native_max_value + assert entity0.native_min_value == native_min_value + assert entity0.native_step == native_step + assert entity0.native_value == native_value + assert type(entity0.native_value) == native_value_type + assert entity0.native_unit_of_measurement == uom diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index 1f5d39ed5e9..f51d3933b5d 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import number from homeassistant.components.number import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index dcf591b83ae..850d330c9ae 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -105,13 +105,13 @@ WEATHER_EXPECTED_OBSERVATION_IMPERIAL = { ), ATTR_WEATHER_WIND_BEARING: 180, ATTR_WEATHER_WIND_SPEED: round( - convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) + convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR), 2 ), ATTR_WEATHER_PRESSURE: round( convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2 ), ATTR_WEATHER_VISIBILITY: round( - convert_distance(10000, LENGTH_METERS, LENGTH_MILES) + convert_distance(10000, LENGTH_METERS, LENGTH_MILES), 2 ), ATTR_WEATHER_HUMIDITY: 10, } @@ -161,7 +161,7 @@ EXPECTED_FORECAST_METRIC = { convert_temperature(10, TEMP_FAHRENHEIT, TEMP_CELSIUS), 1 ), ATTR_FORECAST_WIND_SPEED: round( - convert_speed(10, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR) + convert_speed(10, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR), 2 ), ATTR_FORECAST_WIND_BEARING: 180, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90, diff --git a/tests/components/nx584/test_binary_sensor.py b/tests/components/nx584/test_binary_sensor.py index 83f8a49c091..290567345ca 100644 --- a/tests/components/nx584/test_binary_sensor.py +++ b/tests/components/nx584/test_binary_sensor.py @@ -153,11 +153,28 @@ def test_nx584_zone_sensor_normal(): assert not sensor.should_poll assert sensor.is_on assert sensor.extra_state_attributes["zone_number"] == 1 + assert not sensor.extra_state_attributes["bypassed"] zone["state"] = False assert not sensor.is_on +def test_nx584_zone_sensor_bypassed(): + """Test for the NX584 zone sensor.""" + zone = {"number": 1, "name": "foo", "state": True, "bypassed": True} + sensor = nx584.NX584ZoneSensor(zone, "motion") + assert sensor.name == "foo" + assert not sensor.should_poll + assert sensor.is_on + assert sensor.extra_state_attributes["zone_number"] == 1 + assert sensor.extra_state_attributes["bypassed"] + + zone["state"] = False + zone["bypassed"] = False + assert not sensor.is_on + assert not sensor.extra_state_attributes["bypassed"] + + @mock.patch.object(nx584.NX584ZoneSensor, "schedule_update_ha_state") def test_nx584_watcher_process_zone_event(mock_update): """Test the processing of zone events.""" diff --git a/tests/components/nzbget/__init__.py b/tests/components/nzbget/__init__.py index 9993bdaff1e..331b45e3de8 100644 --- a/tests/components/nzbget/__init__.py +++ b/tests/components/nzbget/__init__.py @@ -1,5 +1,4 @@ """Tests for the NZBGet integration.""" -from datetime import timedelta from unittest.mock import patch from homeassistant.components.nzbget.const import DOMAIN @@ -37,16 +36,6 @@ USER_INPUT = { CONF_USERNAME: "", } -YAML_CONFIG = { - CONF_HOST: "10.10.10.30", - CONF_NAME: "GetNZBsTest", - CONF_PASSWORD: "", - CONF_PORT: 6789, - CONF_SCAN_INTERVAL: timedelta(seconds=5), - CONF_SSL: False, - CONF_USERNAME: "", -} - MOCK_VERSION = "21.0" MOCK_STATUS = { @@ -84,13 +73,6 @@ async def init_integration( return entry -def _patch_async_setup(return_value=True): - return patch( - "homeassistant.components.nzbget.async_setup", - return_value=return_value, - ) - - def _patch_async_setup_entry(return_value=True): return patch( "homeassistant.components.nzbget.async_setup_entry", diff --git a/tests/components/nzbget/test_config_flow.py b/tests/components/nzbget/test_config_flow.py index f6e91d13d9e..8799e3adcf0 100644 --- a/tests/components/nzbget/test_config_flow.py +++ b/tests/components/nzbget/test_config_flow.py @@ -15,7 +15,6 @@ from homeassistant.data_entry_flow import ( from . import ( ENTRY_CONFIG, USER_INPUT, - _patch_async_setup, _patch_async_setup_entry, _patch_history, _patch_status, @@ -34,7 +33,7 @@ async def test_user_form(hass): assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} - with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -45,7 +44,6 @@ async def test_user_form(hass): assert result["title"] == "10.10.10.30" assert result["data"] == {**USER_INPUT, CONF_VERIFY_SSL: False} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -63,7 +61,7 @@ async def test_user_form_show_advanced_options(hass): CONF_VERIFY_SSL: True, } - with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input_advanced, @@ -74,7 +72,6 @@ async def test_user_form_show_advanced_options(hass): assert result["title"] == "10.10.10.30" assert result["data"] == {**USER_INPUT, CONF_VERIFY_SSL: True} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -149,7 +146,7 @@ async def test_options_flow(hass, nzbget_api): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "init" - with _patch_async_setup(), _patch_async_setup_entry(): + with _patch_async_setup_entry(): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SCAN_INTERVAL: 15}, diff --git a/tests/components/nzbget/test_init.py b/tests/components/nzbget/test_init.py index e83672769da..fbb65a4f8b2 100644 --- a/tests/components/nzbget/test_init.py +++ b/tests/components/nzbget/test_init.py @@ -5,36 +5,12 @@ from pynzbgetapi import NZBGetAPIException from homeassistant.components.nzbget.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT -from homeassistant.setup import async_setup_component -from . import ( - ENTRY_CONFIG, - YAML_CONFIG, - _patch_async_setup_entry, - _patch_history, - _patch_status, - _patch_version, - init_integration, -) +from . import ENTRY_CONFIG, _patch_version, init_integration from tests.common import MockConfigEntry -async def test_import_from_yaml(hass) -> None: - """Test import from YAML.""" - with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup_entry(): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_NAME] == "GetNZBsTest" - assert entries[0].data[CONF_HOST] == "10.10.10.30" - assert entries[0].data[CONF_PORT] == 6789 - - async def test_unload_entry(hass, nzbget_api): """Test successful unload of entry.""" entry = await init_integration(hass) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 025459e73b7..204eb6bf772 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -57,7 +57,7 @@ async def mock_supervisor_fixture(hass, aioclient_mock): """Mock supervisor.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) - with patch.dict(os.environ, {"HASSIO": "127.0.0.1"}), patch( + with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value=True, ), patch( @@ -79,7 +79,7 @@ async def mock_supervisor_fixture(hass, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": {}}, ), patch.dict( - os.environ, {"HASSIO_TOKEN": "123456"} + os.environ, {"SUPERVISOR_TOKEN": "123456"} ): yield @@ -144,6 +144,12 @@ async def test_onboarding_user_already_done(hass, hass_storage, hass_client_no_a async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): """Test creating a new user.""" + area_registry = ar.async_get(hass) + + # Create an existing area to mimic an integration creating an area + # before onboarding is done. + area_registry.async_create("Living Room") + assert await async_setup_component(hass, "person", {}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() @@ -194,7 +200,6 @@ async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): ) # Validate created areas - area_registry = ar.async_get(hass) assert len(area_registry.areas) == 3 assert sorted(area.name for area in area_registry.async_list_areas()) == [ "Bedroom", diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index d189db8af1a..c916c777248 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_NAME, ATTR_STATE, ATTR_VIA_DEVICE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry @@ -90,7 +91,7 @@ def check_entities( def setup_owproxy_mock_devices( - owproxy: MagicMock, platform: str, device_ids: list[str] + owproxy: MagicMock, platform: Platform, device_ids: list[str] ) -> None: """Set up mock for owproxy.""" main_dir_return_value = [] @@ -125,7 +126,7 @@ def _setup_owproxy_mock_device( main_read_side_effect: list, sub_read_side_effect: list, device_id: str, - platform: str, + platform: Platform, ) -> None: """Set up mock for owproxy.""" mock_device = MOCK_OWPROXY_DEVICES[device_id] @@ -167,7 +168,7 @@ def _setup_owproxy_mock_device_reads( sub_read_side_effect: list, mock_device: Any, device_id: str, - platform: str, + platform: Platform, ) -> None: """Set up mock for owproxy.""" # Setup device reads diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index fecade521a8..bc09432ea5c 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,12 +1,35 @@ """Tests for 1-Wire config flow.""" -from unittest.mock import MagicMock +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock, patch +import aiohttp from pyownet import protocol import pytest from homeassistant.components.onewire.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import setup_owproxy_mock_devices + + +async def remove_device( + ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str +) -> bool: + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 1, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] @pytest.mark.usefixtures("owproxy_with_connerror") @@ -48,3 +71,44 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): assert config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +@patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) +async def test_registry_cleanup( + hass: HomeAssistant, + config_entry: ConfigEntry, + owproxy: MagicMock, + hass_ws_client: Callable[ + [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] + ], +): + """Test being able to remove a disconnected device.""" + assert await async_setup_component(hass, "config", {}) + + entry_id = config_entry.entry_id + device_registry = dr.async_get(hass) + live_id = "10.111111111111" + dead_id = "28.111111111111" + + # Initialise with two components + setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id, dead_id]) + await hass.config_entries.async_setup(entry_id) + await hass.async_block_till_done() + + # Reload with a device no longer on bus + setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id]) + await hass.config_entries.async_reload(entry_id) + await hass.async_block_till_done() + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + + # Try to remove "10.111111111111" - fails as it is live + device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) + assert await remove_device(await hass_ws_client(hass), device.id, entry_id) is False + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + assert device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) is not None + + # Try to remove "28.111111111111" - succeeds as it is dead + device = device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) + assert await remove_device(await hass_ws_client(hass), device.id, entry_id) is True + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 1 + assert device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) is None diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c22890ebef3..f02abd834d7 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -193,6 +193,8 @@ async def test_single_available_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + await hass.config_entries.async_unload(result["result"].entry_id) + async def test_multiple_servers_with_selection( hass, @@ -249,6 +251,8 @@ async def test_multiple_servers_with_selection( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + await hass.config_entries.async_unload(result["result"].entry_id) + async def test_adding_last_unconfigured_server( hass, @@ -305,6 +309,8 @@ async def test_adding_last_unconfigured_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + await hass.config_entries.async_unload(result["result"].entry_id) + async def test_all_available_servers_configured( hass, diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index bbab50a7bbb..94278ca6052 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -184,6 +184,7 @@ async def test_setup_when_certificate_changed( plextv_account, plextv_resources, plextv_shared_users, + mock_websocket, ): """Test setup component when the Plex certificate has changed.""" diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index e8cc9c56c0a..02f873c6a4a 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch from aioqsw.const import API_MAC_ADDR, API_PRODUCT, API_RESULT from aioqsw.exceptions import LoginError, QswError -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -16,6 +17,16 @@ from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( + hostname="qsw-m408-4c", + ip="192.168.1.200", + macaddress="245EBE000000", +) + +TEST_PASSWORD = "test-password" +TEST_URL = f"http://{DHCP_SERVICE_INFO.ip}" +TEST_USERNAME = "test-username" + async def test_form(hass: HomeAssistant) -> None: """Test that the form is served with valid input.""" @@ -134,3 +145,128 @@ async def test_login_error(hass: HomeAssistant): ) assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} + + +async def test_dhcp_flow(hass: HomeAssistant) -> None: + """Test that DHCP discovery works.""" + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.qnap_qsw.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_board", + return_value=SYSTEM_BOARD_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_users_login", + return_value=USERS_LOGIN_MOCK, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_URL: TEST_URL, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_flow_error(hass: HomeAssistant) -> None: + """Test that DHCP discovery fails.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + side_effect=QswError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_connection_error(hass: HomeAssistant): + """Test DHCP connection to host error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + side_effect=QswError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_dhcp_login_error(hass: HomeAssistant): + """Test DHCP login error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + side_effect=LoginError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} diff --git a/tests/components/radiotherm/__init__.py b/tests/components/radiotherm/__init__.py new file mode 100644 index 00000000000..cf8bc0c7cc5 --- /dev/null +++ b/tests/components/radiotherm/__init__.py @@ -0,0 +1 @@ +"""Tests for the Radio Thermostat integration.""" diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py new file mode 100644 index 00000000000..56a361404f8 --- /dev/null +++ b/tests/components/radiotherm/test_config_flow.py @@ -0,0 +1,265 @@ +"""Test the Radio Thermostat config flow.""" +import socket +from unittest.mock import MagicMock, patch + +from radiotherm import CommonThermostat +from radiotherm.validate import RadiothermTstatError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp +from homeassistant.components.radiotherm.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +def _mock_radiotherm(): + tstat = MagicMock(autospec=CommonThermostat) + tstat.name = {"raw": "My Name"} + tstat.sys = { + "raw": {"uuid": "aabbccddeeff", "fw_version": "1.2.3", "api_version": "4.5.6"} + } + tstat.model = {"raw": "Model"} + return tstat + + +async def test_form(hass): + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "My Name" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown_error(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=RadiothermTstatError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_import(hass): + """Test we get can import from yaml.""" + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My Name" + assert result["data"] == {CONF_HOST: "1.2.3.4"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect(hass): + """Test we abort if we cannot connect on import from yaml.""" + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=socket.timeout, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_can_confirm(hass): + """Test DHCP discovery flow can confirm right away.""" + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "host": "1.2.3.4", + "name": "My Name", + "model": "Model", + } + + with patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "My Name" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_fails_to_connect(hass): + """Test DHCP discovery flow that fails to connect.""" + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=RadiothermTstatError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_already_exists(hass): + """Test DHCP discovery flow that fails to connect.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_unique_id_already_exists(hass): + """Test creating an entry where the unique_id already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/raspberry_pi/test_hardware.py b/tests/components/raspberry_pi/test_hardware.py index 748972c8d60..a4e938079d3 100644 --- a/tests/components/raspberry_pi/test_hardware.py +++ b/tests/components/raspberry_pi/test_hardware.py @@ -1,25 +1,41 @@ """Test the Raspberry Pi hardware platform.""" +from unittest.mock import patch + import pytest -from homeassistant.components.hassio import DATA_OS_INFO from homeassistant.components.raspberry_pi.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import MockModule, mock_integration +from tests.common import MockConfigEntry, MockModule, mock_integration async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: """Test we can get the board info.""" mock_integration(hass, MockModule("hassio")) - hass.data[DATA_OS_INFO] = {"board": "rpi"} - assert await async_setup_component(hass, DOMAIN, {}) + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Raspberry Pi", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.raspberry_pi.get_os_info", + return_value={"board": "rpi"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() client = await hass_ws_client(hass) - await client.send_json({"id": 1, "type": "hardware/info"}) - msg = await client.receive_json() + with patch( + "homeassistant.components.raspberry_pi.hardware.get_os_info", + return_value={"board": "rpi"}, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() assert msg["id"] == 1 assert msg["success"] @@ -43,14 +59,30 @@ async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: async def test_hardware_info_fail(hass: HomeAssistant, hass_ws_client, os_info) -> None: """Test async_info raises if os_info is not as expected.""" mock_integration(hass, MockModule("hassio")) - hass.data[DATA_OS_INFO] = os_info - assert await async_setup_component(hass, DOMAIN, {}) + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Raspberry Pi", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.raspberry_pi.get_os_info", + return_value={"board": "rpi"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() client = await hass_ws_client(hass) - await client.send_json({"id": 1, "type": "hardware/info"}) - msg = await client.receive_json() + with patch( + "homeassistant.components.raspberry_pi.hardware.get_os_info", + return_value=os_info, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() assert msg["id"] == 1 assert msg["success"] diff --git a/tests/components/raspberry_pi/test_init.py b/tests/components/raspberry_pi/test_init.py index dd86da7bce0..4bf64c7999a 100644 --- a/tests/components/raspberry_pi/test_init.py +++ b/tests/components/raspberry_pi/test_init.py @@ -1,6 +1,8 @@ """Test the Raspberry Pi integration.""" from unittest.mock import patch +import pytest + from homeassistant.components.raspberry_pi.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -8,6 +10,16 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(autouse=True) +def mock_rpi_power(): + """Mock the rpi_power integration.""" + with patch( + "homeassistant.components.rpi_power.async_setup_entry", + return_value=True, + ): + yield + + async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup of a config entry.""" mock_integration(hass, MockModule("hassio")) @@ -20,14 +32,19 @@ async def test_setup_entry(hass: HomeAssistant) -> None: title="Raspberry Pi", ) config_entry.add_to_hass(hass) + assert not hass.config_entries.async_entries("rpi_power") with patch( "homeassistant.components.raspberry_pi.get_os_info", return_value={"board": "rpi"}, - ) as mock_get_os_info: + ) as mock_get_os_info, patch( + "homeassistant.components.rpi_power.config_flow.new_under_voltage" + ): 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(hass.config_entries.async_entries("rpi_power")) == 1 + async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: """Test setup of a config entry with wrong board type.""" diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 39cde4c2e7c..20df89eca5b 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -14,13 +14,13 @@ from homeassistant import core as ha from homeassistant.components import recorder from homeassistant.components.recorder import get_instance, statistics from homeassistant.components.recorder.core import Recorder -from homeassistant.components.recorder.models import RecorderRuns +from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, fire_time_changed -from tests.components.recorder import models_schema_0 +from tests.components.recorder import db_schema_0 DEFAULT_PURGE_TASKS = 3 @@ -122,7 +122,7 @@ def create_engine_test(*args, **kwargs): This simulates an existing db with the old schema. """ engine = create_engine(*args, **kwargs) - models_schema_0.Base.metadata.create_all(engine) + db_schema_0.Base.metadata.create_all(engine) return engine diff --git a/tests/components/recorder/models_schema_0.py b/tests/components/recorder/db_schema_0.py similarity index 100% rename from tests/components/recorder/models_schema_0.py rename to tests/components/recorder/db_schema_0.py diff --git a/tests/components/recorder/models_schema_16.py b/tests/components/recorder/db_schema_16.py similarity index 100% rename from tests/components/recorder/models_schema_16.py rename to tests/components/recorder/db_schema_16.py diff --git a/tests/components/recorder/models_schema_18.py b/tests/components/recorder/db_schema_18.py similarity index 100% rename from tests/components/recorder/models_schema_18.py rename to tests/components/recorder/db_schema_18.py diff --git a/tests/components/recorder/models_schema_22.py b/tests/components/recorder/db_schema_22.py similarity index 100% rename from tests/components/recorder/models_schema_22.py rename to tests/components/recorder/db_schema_22.py diff --git a/tests/components/recorder/models_schema_23.py b/tests/components/recorder/db_schema_23.py similarity index 100% rename from tests/components/recorder/models_schema_23.py rename to tests/components/recorder/db_schema_23.py diff --git a/tests/components/recorder/models_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py similarity index 100% rename from tests/components/recorder/models_schema_23_with_newer_columns.py rename to tests/components/recorder/db_schema_23_with_newer_columns.py diff --git a/tests/components/recorder/models_schema_28.py b/tests/components/recorder/db_schema_28.py similarity index 100% rename from tests/components/recorder/models_schema_28.py rename to tests/components/recorder/db_schema_28.py diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index 0758d6fdc95..62bb1b3fa8d 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -5,12 +5,12 @@ from sqlalchemy import select from sqlalchemy.engine.row import Row from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.db_schema import EventData, States from homeassistant.components.recorder.filters import ( Filters, extract_include_exclude_filter_conf, sqlalchemy_filter_from_include_exclude_conf, ) -from homeassistant.components.recorder.models import EventData, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant @@ -514,3 +514,128 @@ async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_ assert filtered_events_entity_ids == filter_accept assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins(hass, recorder_mock): + """Test specificlly included entity always wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + "binary_sensor.specific_include", + } + filter_reject = { + "binary_sensor.test2", + "binary_sensor.home", + "binary_sensor.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["binary_sensor.specific_include"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["binary_sensor"], + CONF_ENTITY_GLOBS: ["binary_sensor.*"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins_over_glob(hass, recorder_mock): + """Test specificlly included entity always wins over a glob.""" + filter_accept = { + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + "sensor.energy_x", + } + filter_reject = { + "sensor.apc900va_not_included", + } + conf = { + CONF_EXCLUDE: { + CONF_DOMAINS: [ + "updater", + "camera", + "group", + "media_player", + "script", + "sun", + "automation", + "zone", + "weblink", + "scene", + "calendar", + "weather", + "remote", + "notify", + "switch", + "shell_command", + "media_player", + ], + CONF_ENTITY_GLOBS: ["sensor.apc900va_*"], + }, + CONF_INCLUDE: { + CONF_DOMAINS: [ + "binary_sensor", + "climate", + "device_tracker", + "input_boolean", + "sensor", + ], + CONF_ENTITY_GLOBS: ["sensor.energy_*"], + CONF_ENTITIES: [ + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + ], + }, + } + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index ee02ffbec49..cc1d8e7faa7 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -12,14 +12,13 @@ from sqlalchemy import text from homeassistant.components import recorder from homeassistant.components.recorder import history -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( Events, - LazyState, RecorderRuns, StateAttributes, States, - process_timestamp, ) +from homeassistant.components.recorder.models import LazyState, process_timestamp from homeassistant.components.recorder.util import session_scope import homeassistant.core as ha from homeassistant.core import HomeAssistant, State diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 87dbce3ba3b..3e25a54e39d 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -25,7 +25,7 @@ from homeassistant.components.recorder import ( get_instance, ) from homeassistant.components.recorder.const import DATA_INSTANCE, KEEPALIVE_TIME -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, EventData, Events, @@ -33,8 +33,8 @@ from homeassistant.components.recorder.models import ( StateAttributes, States, StatisticsRuns, - process_timestamp, ) +from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.services import ( SERVICE_DISABLE, SERVICE_ENABLE, diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fcc35938088..38d6a191809 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -20,9 +20,9 @@ from sqlalchemy.pool import StaticPool from homeassistant.bootstrap import async_setup_component from homeassistant.components import persistent_notification as pn, recorder -from homeassistant.components.recorder import migration, models +from homeassistant.components.recorder import db_schema, migration from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, RecorderRuns, States, @@ -66,7 +66,7 @@ async def test_schema_update_calls(hass): update.assert_has_calls( [ call(hass, engine, session_maker, version + 1, 0) - for version in range(0, models.SCHEMA_VERSION) + for version in range(0, db_schema.SCHEMA_VERSION) ] ) @@ -267,14 +267,16 @@ async def test_schema_migrate(hass, start_version): This simulates an existing db with the old schema. """ - module = f"tests.components.recorder.models_schema_{str(start_version)}" + module = f"tests.components.recorder.db_schema_{str(start_version)}" importlib.import_module(module) old_models = sys.modules[module] engine = create_engine(*args, **kwargs) old_models.Base.metadata.create_all(engine) if start_version > 0: with Session(engine) as session: - session.add(recorder.models.SchemaChanges(schema_version=start_version)) + session.add( + recorder.db_schema.SchemaChanges(schema_version=start_version) + ) session.commit() return engine @@ -299,8 +301,8 @@ async def test_schema_migrate(hass, start_version): # the recorder will silently create a new database. with session_scope(hass=hass) as session: res = ( - session.query(models.SchemaChanges) - .order_by(models.SchemaChanges.change_id.desc()) + session.query(db_schema.SchemaChanges) + .order_by(db_schema.SchemaChanges.change_id.desc()) .first() ) migration_version = res.schema_version @@ -325,7 +327,7 @@ async def test_schema_migrate(hass, start_version): await hass.async_block_till_done() await hass.async_add_executor_job(migration_done.wait) await async_wait_recording_done(hass) - assert migration_version == models.SCHEMA_VERSION + assert migration_version == db_schema.SCHEMA_VERSION assert setup_run.called assert recorder.util.async_migration_in_progress(hass) is not True @@ -381,7 +383,7 @@ def test_forgiving_add_column(): def test_forgiving_add_index(): """Test that add index will continue if index exists.""" engine = create_engine("sqlite://", poolclass=StaticPool) - models.Base.metadata.create_all(engine) + db_schema.Base.metadata.create_all(engine) with Session(engine) as session: instance = Mock() instance.get_session = Mock(return_value=session) diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 9d07c33a17a..81469ab1dab 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -7,14 +7,16 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session, sessionmaker -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( Base, EventData, Events, - LazyState, RecorderRuns, StateAttributes, States, +) +from homeassistant.components.recorder.models import ( + LazyState, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index f4e998c5388..c6c447c01c9 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -10,7 +10,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder.const import MAX_ROWS_TO_PURGE, SupportedDialect -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( EventData, Events, RecorderRuns, diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/test_run_history.py index 80797c666ec..ff4a5e5d701 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/test_run_history.py @@ -3,7 +3,8 @@ from datetime import timedelta from homeassistant.components import recorder -from homeassistant.components.recorder.models import RecorderRuns, process_timestamp +from homeassistant.components.recorder.db_schema import RecorderRuns +from homeassistant.components.recorder.models import process_timestamp from homeassistant.util import dt as dt_util diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 97e64716f49..48639790d0d 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -13,10 +13,8 @@ from sqlalchemy.orm import Session from homeassistant.components import recorder from homeassistant.components.recorder import history, statistics from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX -from homeassistant.components.recorder.models import ( - StatisticsShortTerm, - process_timestamp_to_utc_isoformat, -) +from homeassistant.components.recorder.db_schema import StatisticsShortTerm +from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.recorder.statistics import ( async_add_external_statistics, delete_statistics_duplicates, @@ -390,7 +388,7 @@ def test_rename_entity_collision(hass_recorder, caplog): } with session_scope(hass=hass) as session: - session.add(recorder.models.StatisticsMeta.from_meta(metadata_1)) + session.add(recorder.db_schema.StatisticsMeta.from_meta(metadata_1)) # Rename entity sensor.test1 to sensor.test99 @callback @@ -941,7 +939,7 @@ def test_duplicate_statistics_handle_integrity_error(hass_recorder, caplog): assert insert_statistics_mock.call_count == 3 with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.Statistics).all() + tmp = session.query(recorder.db_schema.Statistics).all() assert len(tmp) == 2 assert "Blocked attempt to insert duplicated statistic rows" in caplog.text @@ -952,15 +950,19 @@ def _create_engine_28(*args, **kwargs): This simulates an existing db with the old schema. """ - module = "tests.components.recorder.models_schema_28" + module = "tests.components.recorder.db_schema_28" importlib.import_module(module) - old_models = sys.modules[module] + old_db_schema = sys.modules[module] engine = create_engine(*args, **kwargs) - old_models.Base.metadata.create_all(engine) + old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: - session.add(recorder.models.StatisticsRuns(start=statistics.get_start_time())) session.add( - recorder.models.SchemaChanges(schema_version=old_models.SCHEMA_VERSION) + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) ) session.commit() return engine @@ -971,9 +973,9 @@ def test_delete_metadata_duplicates(caplog, tmpdir): test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - module = "tests.components.recorder.models_schema_28" + module = "tests.components.recorder.db_schema_28" importlib.import_module(module) - old_models = sys.modules[module] + old_db_schema = sys.modules[module] external_energy_metadata_1 = { "has_mean": False, @@ -1001,8 +1003,8 @@ def test_delete_metadata_duplicates(caplog, tmpdir): } # Create some duplicated statistics_meta with schema version 28 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch( "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 ): @@ -1013,15 +1015,17 @@ def test_delete_metadata_duplicates(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.StatisticsMeta).all() + tmp = session.query(recorder.db_schema.StatisticsMeta).all() assert len(tmp) == 3 assert tmp[0].id == 1 assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" @@ -1042,7 +1046,7 @@ def test_delete_metadata_duplicates(caplog, tmpdir): assert "Deleted 1 duplicated statistics_meta rows" in caplog.text with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.StatisticsMeta).all() + tmp = session.query(recorder.db_schema.StatisticsMeta).all() assert len(tmp) == 2 assert tmp[0].id == 2 assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" @@ -1058,9 +1062,9 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - module = "tests.components.recorder.models_schema_28" + module = "tests.components.recorder.db_schema_28" importlib.import_module(module) - old_models = sys.modules[module] + old_db_schema = sys.modules[module] external_energy_metadata_1 = { "has_mean": False, @@ -1088,8 +1092,8 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): } # Create some duplicated statistics with schema version 28 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch( "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 ): @@ -1100,20 +1104,26 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) for _ in range(3000): session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta( + external_energy_metadata_1 + ) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -1127,7 +1137,7 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): assert "Deleted 3002 duplicated statistics_meta rows" in caplog.text with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.StatisticsMeta).all() + tmp = session.query(recorder.db_schema.StatisticsMeta).all() assert len(tmp) == 3 assert tmp[0].id == 3001 assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index d487743a87f..50311a987d6 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -25,7 +25,7 @@ from tests.components.recorder.common import wait_recording_done ORIG_TZ = dt_util.DEFAULT_TIME_ZONE CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.models_schema_23_with_newer_columns" +SCHEMA_MODULE = "tests.components.recorder.db_schema_23_with_newer_columns" def _create_engine_test(*args, **kwargs): @@ -34,13 +34,17 @@ def _create_engine_test(*args, **kwargs): This simulates an existing db with the old schema. """ importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] engine = create_engine(*args, **kwargs) - old_models.Base.metadata.create_all(engine) + old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: - session.add(recorder.models.StatisticsRuns(start=statistics.get_start_time())) session.add( - recorder.models.SchemaChanges(schema_version=old_models.SCHEMA_VERSION) + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) ) session.commit() return engine @@ -52,7 +56,7 @@ def test_delete_duplicates(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) @@ -171,8 +175,8 @@ def test_delete_duplicates(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -181,19 +185,21 @@ def test_delete_duplicates(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) with session_scope(hass=hass) as session: for stat in external_energy_statistics_1: - session.add(recorder.models.Statistics.from_stats(1, stat)) + session.add(recorder.db_schema.Statistics.from_stats(1, stat)) for stat in external_energy_statistics_2: - session.add(recorder.models.Statistics.from_stats(2, stat)) + session.add(recorder.db_schema.Statistics.from_stats(2, stat)) for stat in external_co2_statistics: - session.add(recorder.models.Statistics.from_stats(3, stat)) + session.add(recorder.db_schema.Statistics.from_stats(3, stat)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -218,7 +224,7 @@ def test_delete_duplicates_many(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) @@ -337,8 +343,8 @@ def test_delete_duplicates_many(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -347,25 +353,27 @@ def test_delete_duplicates_many(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) with session_scope(hass=hass) as session: for stat in external_energy_statistics_1: - session.add(recorder.models.Statistics.from_stats(1, stat)) + session.add(recorder.db_schema.Statistics.from_stats(1, stat)) for _ in range(3000): session.add( - recorder.models.Statistics.from_stats( + recorder.db_schema.Statistics.from_stats( 1, external_energy_statistics_1[-1] ) ) for stat in external_energy_statistics_2: - session.add(recorder.models.Statistics.from_stats(2, stat)) + session.add(recorder.db_schema.Statistics.from_stats(2, stat)) for stat in external_co2_statistics: - session.add(recorder.models.Statistics.from_stats(3, stat)) + session.add(recorder.db_schema.Statistics.from_stats(3, stat)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -391,7 +399,7 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) @@ -480,8 +488,8 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -490,16 +498,16 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) ) with session_scope(hass=hass) as session: for stat in external_energy_statistics_1: - session.add(recorder.models.Statistics.from_stats(1, stat)) + session.add(recorder.db_schema.Statistics.from_stats(1, stat)) for stat in external_energy_statistics_2: - session.add(recorder.models.Statistics.from_stats(2, stat)) + session.add(recorder.db_schema.Statistics.from_stats(2, stat)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -560,7 +568,7 @@ def test_delete_duplicates_short_term(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) @@ -580,8 +588,8 @@ def test_delete_duplicates_short_term(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -590,14 +598,14 @@ def test_delete_duplicates_short_term(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsShortTerm.from_stats(1, statistic_row) + recorder.db_schema.StatisticsShortTerm.from_stats(1, statistic_row) ) session.add( - recorder.models.StatisticsShortTerm.from_stats(1, statistic_row) + recorder.db_schema.StatisticsShortTerm.from_stats(1, statistic_row) ) hass.stop() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 6ac6aabd023..8624719f951 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -9,11 +9,13 @@ from sqlalchemy import text from sqlalchemy.engine.result import ChunkedIteratorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.sql.elements import TextClause +from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder from homeassistant.components.recorder import history, util from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX -from homeassistant.components.recorder.models import RecorderRuns, UnsupportedDialect +from homeassistant.components.recorder.db_schema import RecorderRuns +from homeassistant.components.recorder.models import UnsupportedDialect from homeassistant.components.recorder.util import ( end_incomplete_runs, is_second_sunday, @@ -711,8 +713,8 @@ def test_build_mysqldb_conv(): @patch("homeassistant.components.recorder.util.QUERY_RETRY_WAIT", 0) -def test_execute_stmt(hass_recorder): - """Test executing with execute_stmt.""" +def test_execute_stmt_lambda_element(hass_recorder): + """Test executing with execute_stmt_lambda_element.""" hass = hass_recorder() instance = recorder.get_instance(hass) hass.states.set("sensor.on", "on") @@ -723,15 +725,13 @@ def test_execute_stmt(hass_recorder): one_week_from_now = now + timedelta(days=7) class MockExecutor: - - _calls = 0 - def __init__(self, stmt): - """Init the mock.""" + assert isinstance(stmt, StatementLambdaElement) + self.calls = 0 def all(self): - MockExecutor._calls += 1 - if MockExecutor._calls == 2: + self.calls += 1 + if self.calls == 2: return ["mock_row"] raise SQLAlchemyError @@ -740,24 +740,24 @@ def test_execute_stmt(hass_recorder): stmt = history._get_single_entity_states_stmt( instance.schema_version, dt_util.utcnow(), "sensor.on", False ) - rows = util.execute_stmt(session, stmt) + rows = util.execute_stmt_lambda_element(session, stmt) assert isinstance(rows, list) assert rows[0].state == new_state.state assert rows[0].entity_id == new_state.entity_id # Time window >= 2 days, we get a ChunkedIteratorResult - rows = util.execute_stmt(session, stmt, now, one_week_from_now) + rows = util.execute_stmt_lambda_element(session, stmt, now, one_week_from_now) assert isinstance(rows, ChunkedIteratorResult) row = next(rows) assert row.state == new_state.state assert row.entity_id == new_state.entity_id # Time window < 2 days, we get a list - rows = util.execute_stmt(session, stmt, now, tomorrow) + rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) assert isinstance(rows, list) assert rows[0].state == new_state.state assert rows[0].entity_id == new_state.entity_id with patch.object(session, "execute", MockExecutor): - rows = util.execute_stmt(session, stmt, now, tomorrow) + rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) assert rows == ["mock_row"] diff --git a/tests/components/rest/fixtures/configuration_invalid.notyaml b/tests/components/rest/fixtures/configuration_invalid.notyaml index 548d8bcf5a0..4afb3b7ce96 100644 --- a/tests/components/rest/fixtures/configuration_invalid.notyaml +++ b/tests/components/rest/fixtures/configuration_invalid.notyaml @@ -1,2 +1,2 @@ -*!* NOT YAML +-*!*- NOT YAML diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 8383d53b51f..a6655f6ddbc 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -19,6 +19,8 @@ from homeassistant.const import ( STATE_UNAVAILABLE, Platform, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -431,3 +433,40 @@ async def test_setup_query_params(hass): ) await hass.async_block_till_done() assert len(hass.states.async_all("binary_sensor")) == 1 + + +@respx.mock +async def test_entity_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + Platform.BINARY_SENSOR: { + # REST configuration + "platform": "rest", + "method": "GET", + "resource": "http://localhost", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "name": "{{'REST' + ' ' + 'Binary Sensor'}}", + "unique_id": "very_unique", + }, + } + + respx.get("http://localhost") % HTTPStatus.OK + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get("binary_sensor.rest_binary_sensor").unique_id + == "very_unique" + ) + + state = hass.states.get("binary_sensor.rest_binary_sensor") + assert state.state == "off" + assert state.attributes == { + "entity_picture": "blabla.png", + "friendly_name": "REST Binary Sensor", + "icon": "mdi:one_two_three", + } diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 86ce816f932..a89d20f2510 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -24,6 +24,8 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -864,3 +866,43 @@ async def test_reload(hass): assert hass.states.get("sensor.mockreset") is None assert hass.states.get("sensor.rollout") + + +@respx.mock +async def test_entity_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + DOMAIN: { + # REST configuration + "platform": "rest", + "method": "GET", + "resource": "http://localhost", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "device_class": "temperature", + "name": "{{'REST' + ' ' + 'Sensor'}}", + "state_class": "measurement", + "unique_id": "very_unique", + "unit_of_measurement": "beardsecond", + }, + } + + respx.get("http://localhost") % HTTPStatus.OK + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert entity_registry.async_get("sensor.rest_sensor").unique_id == "very_unique" + + state = hass.states.get("sensor.rest_sensor") + assert state.state == "" + assert state.attributes == { + "device_class": "temperature", + "entity_picture": "blabla.png", + "friendly_name": "REST Sensor", + "icon": "mdi:one_two_three", + "state_class": "measurement", + "unit_of_measurement": "beardsecond", + } diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 3dbef91ffb5..a3c0f78db1c 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -8,6 +8,7 @@ from homeassistant.components.rest import DOMAIN import homeassistant.components.rest.switch as rest from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_HEADERS, CONF_NAME, CONF_PARAMS, @@ -16,17 +17,19 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, Platform, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from tests.common import assert_setup_component +from tests.test_util.aiohttp import AiohttpClientMocker NAME = "foo" DEVICE_CLASS = SwitchDeviceClass.SWITCH METHOD = "post" RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE -AUTH = None PARAMS = None @@ -187,19 +190,22 @@ def _setup_test_switch(hass): body_off = Template("off", hass) headers = {"Content-type": Template(CONTENT_TYPE_JSON, hass)} switch = rest.RestSwitch( - NAME, - DEVICE_CLASS, - RESOURCE, - STATE_RESOURCE, - METHOD, - headers, - PARAMS, - AUTH, - body_on, - body_off, + hass, + { + CONF_NAME: Template(NAME, hass), + CONF_DEVICE_CLASS: DEVICE_CLASS, + CONF_RESOURCE: RESOURCE, + rest.CONF_STATE_RESOURCE: STATE_RESOURCE, + rest.CONF_METHOD: METHOD, + rest.CONF_HEADERS: headers, + rest.CONF_PARAMS: PARAMS, + rest.CONF_BODY_ON: body_on, + rest.CONF_BODY_OFF: body_off, + rest.CONF_IS_ON_TEMPLATE: None, + rest.CONF_TIMEOUT: 10, + rest.CONF_VERIFY_SSL: True, + }, None, - 10, - True, ) switch.hass = hass return switch, body_on, body_off @@ -315,3 +321,38 @@ async def test_update_timeout(hass, aioclient_mock): await switch.async_update() assert switch.is_on is None + + +async def test_entity_config( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test entity configuration.""" + + aioclient_mock.get("http://localhost", status=HTTPStatus.OK) + config = { + Platform.SWITCH: { + # REST configuration + "platform": "rest", + "method": "POST", + "resource": "http://localhost", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "name": "{{'REST' + ' ' + 'Switch'}}", + "unique_id": "very_unique", + }, + } + + assert await async_setup_component(hass, Platform.SWITCH, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert entity_registry.async_get("switch.rest_switch").unique_id == "very_unique" + + state = hass.states.get("switch.rest_switch") + assert state.state == "unknown" + assert state.attributes == { + "entity_picture": "blabla.png", + "friendly_name": "REST Switch", + "icon": "mdi:one_two_three", + } diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index a756bf26b9f..2c695d71d2e 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -847,7 +847,7 @@ async def test_options_configure_rfy_cover_device(hass): result["flow_id"], user_input={ "automatic_add": True, - "event_code": "071a000001020301", + "event_code": "0C1a0000010203010000000000", }, ) @@ -863,7 +863,10 @@ async def test_options_configure_rfy_cover_device(hass): await hass.async_block_till_done() - assert entry.data["devices"]["071a000001020301"]["venetian_blind_mode"] == "EU" + assert ( + entry.data["devices"]["0C1a0000010203010000000000"]["venetian_blind_mode"] + == "EU" + ) device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) @@ -897,7 +900,10 @@ async def test_options_configure_rfy_cover_device(hass): await hass.async_block_till_done() - assert entry.data["devices"]["071a000001020301"]["venetian_blind_mode"] == "EU" + assert ( + entry.data["devices"]["0C1a0000010203010000000000"]["venetian_blind_mode"] + == "EU" + ) def test_get_serial_by_id_no_dir(): diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py index e3d44edda82..3be41d9233e 100644 --- a/tests/components/rfxtrx/test_cover.py +++ b/tests/components/rfxtrx/test_cover.py @@ -146,8 +146,11 @@ async def test_rfy_cover(hass, rfxtrx): "071a000001020301": { "venetian_blind_mode": "Unknown", }, - "071a000001020302": {"venetian_blind_mode": "US"}, - "071a000001020303": {"venetian_blind_mode": "EU"}, + "0c1a0000010203010000000000": { + "venetian_blind_mode": "Unknown", + }, + "0c1a0000010203020000000000": {"venetian_blind_mode": "US"}, + "0c1a0000010203030000000000": {"venetian_blind_mode": "EU"}, } ) mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) @@ -199,9 +202,9 @@ async def test_rfy_cover(hass, rfxtrx): ) assert rfxtrx.transport.send.mock_calls == [ - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x01\x00")), - call(bytearray(b"\x08\x1a\x00\x01\x01\x02\x03\x01\x01")), - call(bytearray(b"\x08\x1a\x00\x02\x01\x02\x03\x01\x03")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x01\x00\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x01\x01\x02\x03\x01\x01\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x02\x01\x02\x03\x01\x03\x00\x00\x00\x00")), ] # Test a blind with venetian mode set to US @@ -252,12 +255,12 @@ async def test_rfy_cover(hass, rfxtrx): ) assert rfxtrx.transport.send.mock_calls == [ - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x02\x00")), - call(bytearray(b"\x08\x1a\x00\x01\x01\x02\x03\x02\x0F")), - call(bytearray(b"\x08\x1a\x00\x02\x01\x02\x03\x02\x10")), - call(bytearray(b"\x08\x1a\x00\x03\x01\x02\x03\x02\x11")), - call(bytearray(b"\x08\x1a\x00\x04\x01\x02\x03\x02\x12")), - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x02\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x02\x00\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x01\x01\x02\x03\x02\x0F\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x02\x01\x02\x03\x02\x10\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x03\x01\x02\x03\x02\x11\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x04\x01\x02\x03\x02\x12\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x02\x00\x00\x00\x00\x00")), ] # Test a blind with venetian mode set to EU @@ -308,10 +311,10 @@ async def test_rfy_cover(hass, rfxtrx): ) assert rfxtrx.transport.send.mock_calls == [ - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x03\x00")), - call(bytearray(b"\x08\x1a\x00\x01\x01\x02\x03\x03\x11")), - call(bytearray(b"\x08\x1a\x00\x02\x01\x02\x03\x03\x12")), - call(bytearray(b"\x08\x1a\x00\x03\x01\x02\x03\x03\x0F")), - call(bytearray(b"\x08\x1a\x00\x04\x01\x02\x03\x03\x10")), - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x03\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x03\x00\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x01\x01\x02\x03\x03\x11\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x02\x01\x02\x03\x03\x12\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x03\x01\x02\x03\x03\x0F\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x04\x01\x02\x03\x03\x10\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x03\x00\x00\x00\x00\x00")), ] diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 4da7f1d9881..4d92c6fa332 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -11,8 +11,8 @@ from homeassistant.core import State from tests.common import MockConfigEntry, mock_restore_cache from tests.components.rfxtrx.conftest import create_rfx_test_cfg -EVENT_RFY_ENABLE_SUN_AUTO = "081a00000301010113" -EVENT_RFY_DISABLE_SUN_AUTO = "081a00000301010114" +EVENT_RFY_ENABLE_SUN_AUTO = "0C1a0000030101011300000003" +EVENT_RFY_DISABLE_SUN_AUTO = "0C1a0000030101011400000003" async def test_one_switch(hass, rfxtrx): diff --git a/tests/components/roku/test_select.py b/tests/components/roku/test_select.py index e82a13c8511..003487c0adf 100644 --- a/tests/components/roku/test_select.py +++ b/tests/components/roku/test_select.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock import pytest -from rokuecp import Application, Device as RokuDevice, RokuError +from rokuecp import ( + Application, + Device as RokuDevice, + RokuConnectionError, + RokuConnectionTimeoutError, + RokuError, +) from homeassistant.components.roku.const import DOMAIN from homeassistant.components.roku.coordinator import SCAN_INTERVAL @@ -10,6 +16,7 @@ from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.select.const import ATTR_OPTION, ATTR_OPTIONS from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -102,11 +109,20 @@ async def test_application_state( assert state.state == "Home" +@pytest.mark.parametrize( + "error, error_string", + [ + (RokuConnectionError, "Error communicating with Roku API"), + (RokuConnectionTimeoutError, "Timeout communicating with Roku API"), + (RokuError, "Invalid response from Roku API"), + ], +) async def test_application_select_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_roku: MagicMock, - caplog: pytest.LogCaptureFixture, + error: RokuError, + error_string: str, ) -> None: """Test error handling of the Roku selects.""" entity_registry = er.async_get(hass) @@ -123,22 +139,22 @@ async def test_application_select_error( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - mock_roku.launch.side_effect = RokuError + mock_roku.launch.side_effect = error - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.my_roku_3_application", - ATTR_OPTION: "Netflix", - }, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=error_string): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_roku_3_application", + ATTR_OPTION: "Netflix", + }, + blocking=True, + ) state = hass.states.get("select.my_roku_3_application") assert state assert state.state == "Home" - assert "Invalid response from API" in caplog.text assert mock_roku.launch.call_count == 1 mock_roku.launch.assert_called_with("12") @@ -218,24 +234,23 @@ async def test_channel_select_error( hass: HomeAssistant, init_integration: MockConfigEntry, mock_roku: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test error handling of the Roku selects.""" mock_roku.tune.side_effect = RokuError - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.58_onn_roku_tv_channel", - ATTR_OPTION: "99.1", - }, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match="Invalid response from Roku API"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.58_onn_roku_tv_channel", + ATTR_OPTION: "99.1", + }, + blocking=True, + ) state = hass.states.get("select.58_onn_roku_tv_channel") assert state assert state.state == "getTV (14.3)" - assert "Invalid response from API" in caplog.text assert mock_roku.tune.call_count == 1 mock_roku.tune.assert_called_with("99.1") diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index 0dc7bd54746..a023212b82b 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations import pytest from homeassistant.components import script -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.script import ( ATTR_CUR, diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index f48679a43f1..083caef3444 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import select -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.select import ATTR_OPTIONS from homeassistant.const import ATTR_FRIENDLY_NAME diff --git a/tests/components/sensibo/fixtures/data.json b/tests/components/sensibo/fixtures/data.json index c787ea5592c..5837296d154 100644 --- a/tests/components/sensibo/fixtures/data.json +++ b/tests/components/sensibo/fixtures/data.json @@ -156,7 +156,7 @@ "time": "2022-03-12T15:24:26Z", "secondsAgo": 4219143 }, - "shouldCleanFilters": false + "shouldCleanFilters": true }, "serviceSubscriptions": [], "roomIsOccupied": true, @@ -251,7 +251,7 @@ }, "C": { "isNative": true, - "values": [18, 19, 20] + "values": [10, 16, 17, 18, 19, 20] } }, "fanLevels": ["quiet", "low", "medium"], @@ -267,7 +267,7 @@ }, "C": { "isNative": true, - "values": [17, 18, 19, 20] + "values": [10, 16, 17, 18, 19, 20] } }, "fanLevels": ["quiet", "low", "medium"], @@ -283,7 +283,7 @@ }, "C": { "isNative": true, - "values": [18, 19, 20] + "values": [10, 16, 17, 18, 19, 20] } }, "swing": ["stopped", "fixedTop", "fixedMiddleTop"], @@ -298,7 +298,7 @@ }, "C": { "isNative": true, - "values": [18, 19, 20, 21] + "values": [10, 16, 17, 18, 19, 20] } }, "fanLevels": ["quiet", "low", "medium"], diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index 3a84dc99ca5..093cfe7e472 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysensibo.model import SensiboData from pytest import MonkeyPatch @@ -16,6 +16,7 @@ from tests.common import async_fire_time_changed async def test_binary_sensor( hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, load_int: ConfigEntry, monkeypatch: MonkeyPatch, get_data: SensiboData, @@ -26,10 +27,18 @@ async def test_binary_sensor( state2 = hass.states.get("binary_sensor.hallway_motion_sensor_main_sensor") state3 = hass.states.get("binary_sensor.hallway_motion_sensor_motion") state4 = hass.states.get("binary_sensor.hallway_room_occupied") + state5 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" + ) + state6 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" + ) assert state1.state == "on" assert state2.state == "on" assert state3.state == "on" assert state4.state == "on" + assert state5.state == "on" + assert state6.state == "off" monkeypatch.setattr( get_data.parsed["ABC999111"].motion_sensors["AABBCC"], "alive", False diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py new file mode 100644 index 00000000000..66b7a1258b1 --- /dev/null +++ b/tests/components/sensibo/test_button.py @@ -0,0 +1,110 @@ +"""The test for the sensibo button platform.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pysensibo.model import SensiboData +from pytest import MonkeyPatch, raises + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt + +from tests.common import async_fire_time_changed + + +async def test_button( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Sensibo button.""" + + state_button = hass.states.get("button.hallway_reset_filter") + state_filter_clean = hass.states.get("binary_sensor.hallway_filter_clean_required") + state_filter_last_reset = hass.states.get("sensor.hallway_filter_last_reset") + + assert state_button.state is STATE_UNKNOWN + assert state_filter_clean.state is STATE_ON + assert state_filter_last_reset.state == "2022-03-12T15:24:26+00:00" + + freezer.move_to(datetime(2022, 6, 19, 20, 0, 0)) + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", + return_value={"status": "success"}, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: state_button.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "filter_clean", False) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "filter_last_reset", + datetime(2022, 6, 19, 20, 0, 0, tzinfo=dt.UTC), + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_button = hass.states.get("button.hallway_reset_filter") + state_filter_clean = hass.states.get("binary_sensor.hallway_filter_clean_required") + state_filter_last_reset = hass.states.get("sensor.hallway_filter_last_reset") + assert ( + state_button.state == datetime(2022, 6, 19, 20, 0, 0, tzinfo=dt.UTC).isoformat() + ) + assert state_filter_clean.state is STATE_OFF + assert state_filter_last_reset.state == "2022-06-19T20:00:00+00:00" + + +async def test_button_failure( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo button fails.""" + + state_button = hass.states.get("button.hallway_reset_filter") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", + return_value={"status": "failure"}, + ): + with raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: state_button.entity_id, + }, + blocking=True, + ) diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 0b6c043240c..e7a3c465f76 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -1,8 +1,8 @@ """The test for the sensibo binary sensor platform.""" from __future__ import annotations -from datetime import timedelta -from unittest.mock import patch +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch from pysensibo.model import SensiboData import pytest @@ -20,7 +20,18 @@ from homeassistant.components.climate.const import ( SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ) -from homeassistant.components.sensibo.climate import SERVICE_ASSUME_STATE +from homeassistant.components.sensibo.climate import ( + ATTR_AC_INTEGRATION, + ATTR_GEO_INTEGRATION, + ATTR_INDOOR_INTEGRATION, + ATTR_MINUTES, + ATTR_OUTDOOR_INTEGRATION, + ATTR_SENSITIVITY, + SERVICE_ASSUME_STATE, + SERVICE_ENABLE_PURE_BOOST, + SERVICE_ENABLE_TIMER, + _find_valid_target_temp, +) from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,6 +40,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,6 +49,21 @@ from homeassistant.util import dt from tests.common import async_fire_time_changed +async def test_climate_find_valid_targets(): + """Test function to return temperature from valid targets.""" + + valid_targets = [10, 16, 17, 18, 19, 20] + + assert _find_valid_target_temp(7, valid_targets) == 10 + assert _find_valid_target_temp(10, valid_targets) == 10 + assert _find_valid_target_temp(11, valid_targets) == 16 + assert _find_valid_target_temp(15, valid_targets) == 16 + assert _find_valid_target_temp(16, valid_targets) == 16 + assert _find_valid_target_temp(18.5, valid_targets) == 19 + assert _find_valid_target_temp(20, valid_targets) == 20 + assert _find_valid_target_temp(25, valid_targets) == 20 + + async def test_climate( hass: HomeAssistant, load_int: ConfigEntry, get_data: SensiboData ) -> None: @@ -55,7 +82,7 @@ async def test_climate( "fan_only", "off", ], - "min_temp": 17, + "min_temp": 10, "max_temp": 20, "target_temp_step": 1, "fan_modes": ["quiet", "low", "medium"], @@ -244,23 +271,22 @@ async def test_climate_temperatures( await hass.async_block_till_done() state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 17 + assert state2.attributes["temperature"] == 16 with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", return_value={"result": {"status": "Success"}}, ): - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 18.5}, - blocking=True, - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 18.5}, + blocking=True, + ) await hass.async_block_till_done() state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 17 + assert state2.attributes["temperature"] == 19 with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", @@ -624,3 +650,242 @@ async def test_climate_assumed_state( state2 = hass.states.get("climate.hallway") assert state2.state == "off" + + +async def test_climate_no_fan_no_swing( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate fan service.""" + + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] == "high" + assert state.attributes["swing_mode"] == "stopped" + + monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_mode", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_mode", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_modes", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_modes", None) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] is None + assert state.attributes["swing_mode"] is None + assert state.attributes["fan_modes"] is None + assert state.attributes["swing_modes"] is None + + +async def test_climate_set_timer( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate Set Timer service.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_climate = hass.states.get("climate.hallway") + assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_TIMER, + { + ATTR_ENTITY_ID: state_climate.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_TIMER, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_TIMER, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", "SzTGE4oZ4D") + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "timer_time", + datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.hallway_timer_end_time").state + == "2022-06-06T12:00:00+00:00" + ) + + +async def test_climate_pure_boost( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate assumed state service.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_climate = hass.states.get("climate.kitchen") + state2 = hass.states.get("switch.kitchen_pure_boost") + assert state2.state == "off" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_INDOOR_INTEGRATION: True, + ATTR_OUTDOOR_INTEGRATION: True, + ATTR_SENSITIVITY: "Sensitive", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + return_value={ + "status": "success", + "result": { + "enabled": True, + "sensitivity": "S", + "measurements_integration": True, + "ac_integration": False, + "geo_integration": False, + "prime_integration": True, + }, + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_AC_INTEGRATION: False, + ATTR_GEO_INTEGRATION: False, + ATTR_INDOOR_INTEGRATION: True, + ATTR_OUTDOOR_INTEGRATION: True, + ATTR_SENSITIVITY: "Sensitive", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", True) + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_sensitivity", "s") + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_measure_integration", True) + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_prime_integration", True) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.kitchen_pure_boost") + state2 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" + ) + state3 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" + ) + state4 = hass.states.get("sensor.kitchen_pure_sensitivity") + assert state1.state == "on" + assert state2.state == "on" + assert state3.state == "on" + assert state4.state == "s" diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 413b62f6b9f..426416ae2b9 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -24,8 +24,10 @@ async def test_sensor( state1 = hass.states.get("sensor.hallway_motion_sensor_battery_voltage") state2 = hass.states.get("sensor.kitchen_pm2_5") + state3 = hass.states.get("sensor.kitchen_pure_sensitivity") assert state1.state == "3000" assert state2.state == "1" + assert state3.state == "n" assert state2.attributes == { "state_class": "measurement", "unit_of_measurement": "µg/m³", diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py new file mode 100644 index 00000000000..2a24751d70b --- /dev/null +++ b/tests/components/sensibo/test_switch.py @@ -0,0 +1,250 @@ +"""The test for the sensibo switch platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from pysensibo.model import SensiboData +import pytest +from pytest import MonkeyPatch + +from homeassistant.components.sensibo.switch import build_params +from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt + +from tests.common import async_fire_time_changed + + +async def test_switch_timer( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch.""" + + state1 = hass.states.get("switch.hallway_timer") + assert state1.state == STATE_OFF + assert state1.attributes["id"] is None + assert state1.attributes["turn_on"] is None + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", "SzTGE4oZ4D") + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + state1 = hass.states.get("switch.hallway_timer") + assert state1.state == STATE_ON + assert state1.attributes["id"] == "SzTGE4oZ4D" + assert state1.attributes["turn_on"] is False + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", + return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", False) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.hallway_timer") + assert state1.state == STATE_OFF + + +async def test_switch_pure_boost( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch.""" + + state1 = hass.states.get("switch.kitchen_pure_boost") + assert state1.state == STATE_OFF + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + return_value={"status": "success"}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", True) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + state1 = hass.states.get("switch.kitchen_pure_boost") + assert state1.state == STATE_ON + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + return_value={"status": "success"}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", False) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.kitchen_pure_boost") + assert state1.state == STATE_OFF + + +async def test_switch_command_failure( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch fails commands.""" + + state1 = hass.states.get("switch.hallway_timer") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + + +async def test_build_params( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the build params method.""" + + assert build_params("set_timer", get_data.parsed["ABC999111"]) == { + "minutesFromNow": 60, + "acState": {**get_data.parsed["ABC999111"].ac_states, "on": False}, + } + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_measure_integration", None) + assert build_params("set_pure_boost", get_data.parsed["AAZZAAZZ"]) == { + "enabled": True, + "sensitivity": "N", + "measurementsIntegration": True, + "acIntegration": False, + "geoIntegration": False, + "primeIntegration": False, + } + assert build_params("incorrect_command", get_data.parsed["ABC999111"]) is None diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 4a3d0202c91..0bae8235ff9 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -183,8 +183,8 @@ async def test_deprecated_datetime_str( await hass.async_block_till_done() assert ( - f"Invalid {provides}: sensor.test has a {device_class} device class " - f"but does not provide a {provides} state but {type(state_value)}" + f"Invalid {provides}: sensor.test has {device_class} device class " + f"but provides state {state_value}:{type(state_value)}" ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 66ed0032201..c62d1309c7a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -11,10 +11,8 @@ from pytest import approx from homeassistant import loader from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import ( - StatisticsMeta, - process_timestamp_to_utc_isoformat, -) +from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.recorder.statistics import ( get_metadata, list_statistic_ids, @@ -2287,7 +2285,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog): year=2021, month=9, day=1, hour=5, minute=0, second=0, microsecond=0 ) with patch( - "homeassistant.components.recorder.models.dt_util.utcnow", return_value=zero + "homeassistant.components.recorder.db_schema.dt_util.utcnow", return_value=zero ): hass = hass_recorder() # Remove this after dropping the use of the hass_recorder fixture diff --git a/tests/components/sensor/test_significant_change.py b/tests/components/sensor/test_significant_change.py index 051a92f3b07..bfa01d6eb08 100644 --- a/tests/components/sensor/test_significant_change.py +++ b/tests/components/sensor/test_significant_change.py @@ -52,6 +52,8 @@ TEMP_FREEDOM_ATTRS = { ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), ("70", "71", TEMP_FREEDOM_ATTRS, True), ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ("fail", "70", TEMP_FREEDOM_ATTRS, True), + ("70", "fail", TEMP_FREEDOM_ATTRS, False), ], ) async def test_significant_change_temperature(old_state, new_state, attrs, result): diff --git a/tests/components/simplepush/__init__.py b/tests/components/simplepush/__init__.py new file mode 100644 index 00000000000..fd40577f8fa --- /dev/null +++ b/tests/components/simplepush/__init__.py @@ -0,0 +1 @@ +"""Tests for the simeplush integration.""" diff --git a/tests/components/simplepush/test_config_flow.py b/tests/components/simplepush/test_config_flow.py new file mode 100644 index 00000000000..4636df6b28f --- /dev/null +++ b/tests/components/simplepush/test_config_flow.py @@ -0,0 +1,144 @@ +"""Test Simplepush config flow.""" +from unittest.mock import patch + +import pytest +from simplepush import UnknownError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.simplepush.const import CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + CONF_DEVICE_KEY: "abc", + CONF_NAME: "simplepush", +} + + +@pytest.fixture(autouse=True) +def simplepush_setup_fixture(): + """Patch simplepush setup entry.""" + with patch( + "homeassistant.components.simplepush.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture(autouse=True) +def mock_api_request(): + """Patch simplepush api request.""" + with patch("homeassistant.components.simplepush.config_flow.send"), patch( + "homeassistant.components.simplepush.config_flow.send_encrypted" + ): + yield + + +async def test_flow_successful(hass: HomeAssistant) -> None: + """Test user initialized flow with minimum config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == MOCK_CONFIG + + +async def test_flow_with_password(hass: HomeAssistant) -> None: + """Test user initialized flow with password and salt.""" + mock_config_pass = {**MOCK_CONFIG, CONF_PASSWORD: "password", CONF_SALT: "salt"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=mock_config_pass, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == mock_config_pass + + +async def test_flow_user_device_key_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate device key.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="abc", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_name_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="abc", + ) + + entry.add_to_hass(hass) + + new_entry = MOCK_CONFIG.copy() + new_entry[CONF_DEVICE_KEY] = "abc1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_error_on_connection_failure(hass: HomeAssistant) -> None: + """Test when connection to api fails.""" + with patch( + "homeassistant.components.simplepush.config_flow.send", + side_effect=UnknownError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test an import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == MOCK_CONFIG diff --git a/tests/components/siren/test_recorder.py b/tests/components/siren/test_recorder.py index 46e066e4873..aaf1679478a 100644 --- a/tests/components/siren/test_recorder.py +++ b/tests/components/siren/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import siren -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.siren import ATTR_AVAILABLE_TONES from homeassistant.const import ATTR_FRIENDLY_NAME diff --git a/tests/components/skybell/__init__.py b/tests/components/skybell/__init__.py new file mode 100644 index 00000000000..dd162ed5d80 --- /dev/null +++ b/tests/components/skybell/__init__.py @@ -0,0 +1,30 @@ +"""Tests for the SkyBell integration.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +USERNAME = "user" +PASSWORD = "password" +USER_ID = "123456789012345678901234" + +CONF_CONFIG_FLOW = { + CONF_EMAIL: USERNAME, + CONF_PASSWORD: PASSWORD, +} + + +def _patch_skybell_devices() -> None: + mocked_skybell = AsyncMock() + mocked_skybell.user_id = USER_ID + return patch( + "homeassistant.components.skybell.config_flow.Skybell.async_get_devices", + return_value=[mocked_skybell], + ) + + +def _patch_skybell() -> None: + return patch( + "homeassistant.components.skybell.config_flow.Skybell.async_send_request", + return_value={"id": USER_ID}, + ) diff --git a/tests/components/skybell/test_config_flow.py b/tests/components/skybell/test_config_flow.py new file mode 100644 index 00000000000..0171a522e50 --- /dev/null +++ b/tests/components/skybell/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test SkyBell config flow.""" +from unittest.mock import patch + +from aioskybell import exceptions + +from homeassistant.components.skybell.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import CONF_CONFIG_FLOW, _patch_skybell, _patch_skybell_devices + +from tests.common import MockConfigEntry + + +def _patch_setup_entry() -> None: + return patch( + "homeassistant.components.skybell.async_setup_entry", + return_value=True, + ) + + +def _patch_setup() -> None: + return patch( + "homeassistant.components.skybell.async_setup", + return_value=True, + ) + + +async def test_flow_user(hass: HomeAssistant) -> None: + """Test that the user step works.""" + with _patch_skybell(), _patch_skybell_devices(), _patch_setup_entry(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == CONF_CONFIG_FLOW + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_CONFIG_FLOW, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with _patch_skybell() as skybell_mock: + skybell_mock.side_effect = exceptions.SkybellException(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_credentials(hass: HomeAssistant) -> None: + """Test that invalid credentials throws an error.""" + with patch("homeassistant.components.skybell.Skybell.async_login") as skybell_mock: + skybell_mock.side_effect = exceptions.SkybellAuthenticationException(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with _patch_skybell_devices() as skybell_mock: + skybell_mock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + with _patch_skybell(), _patch_skybell_devices(), _patch_setup_entry(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == CONF_CONFIG_FLOW + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="123456789012345678901234", data=CONF_CONFIG_FLOW + ) + + entry.add_to_hass(hass) + + with _patch_skybell(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 5c3d61d8b41..825b8259276 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -148,7 +148,6 @@ def air_conditioner_fixture(device_factory): Capability.air_conditioner_mode, Capability.demand_response_load_control, Capability.air_conditioner_fan_mode, - Capability.power_consumption_report, Capability.switch, Capability.temperature_measurement, Capability.thermostat_cooling_setpoint, @@ -177,12 +176,6 @@ def air_conditioner_fixture(device_factory): "high", "turbo", ], - Attribute.power_consumption: { - "start": "2019-02-24T21:03:04Z", - "power": 0, - "energy": 500, - "end": "2019-02-26T02:05:55Z", - }, Attribute.switch: "on", Attribute.cooling_setpoint: 23, }, @@ -320,10 +313,6 @@ async def test_air_conditioner_entity_state(hass, air_conditioner): assert state.attributes["drlc_status_level"] == -1 assert state.attributes["drlc_status_start"] == "1970-01-01T00:00:00Z" assert state.attributes["drlc_status_override"] is False - assert state.attributes["power_consumption_start"] == "2019-02-24T21:03:04Z" - assert state.attributes["power_consumption_power"] == 0 - assert state.attributes["power_consumption_energy"] == 500 - assert state.attributes["power_consumption_end"] == "2019-02-26T02:05:55Z" async def test_set_fan_mode(hass, thermostat, air_conditioner): diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 98464af24af..a4e89ebe5c7 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -190,6 +190,8 @@ async def test_power_consumption_sensor(hass, device_factory): state = hass.states.get("sensor.refrigerator_power") assert state assert state.state == "109" + assert state.attributes["power_consumption_start"] == "2021-07-30T16:45:25Z" + assert state.attributes["power_consumption_end"] == "2021-07-30T16:58:33Z" entry = entity_registry.async_get("sensor.refrigerator_power") assert entry assert entry.unique_id == f"{device.device_id}.power_meter" diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py index d815aafc8f5..377552da4d5 100644 --- a/tests/components/smhi/__init__.py +++ b/tests/components/smhi/__init__.py @@ -1,3 +1,14 @@ """Tests for the SMHI component.""" ENTITY_ID = "weather.smhi_test" -TEST_CONFIG = {"name": "test", "longitude": "17.84197", "latitude": "59.32624"} +TEST_CONFIG = { + "name": "test", + "location": { + "longitude": "17.84197", + "latitude": "59.32624", + }, +} +TEST_CONFIG_MIGRATE = { + "name": "test", + "longitude": "17.84197", + "latitude": "17.84197", +} diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 60879e8af75..f33849694c8 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -7,13 +7,9 @@ from smhi.smhi_lib import SmhiForecastException from homeassistant import config_entries from homeassistant.components.smhi.const import DOMAIN -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import ( - RESULT_TYPE_ABORT, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_FORM, -) +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -27,7 +23,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"] == RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} with patch( @@ -40,17 +36,21 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 0.0, - CONF_LONGITUDE: 0.0, + CONF_LOCATION: { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + } }, ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Home" assert result2["data"] == { - "latitude": 0.0, - "longitude": 0.0, + "location": { + "latitude": 0.0, + "longitude": 0.0, + }, "name": "Home", } assert len(mock_setup_entry.mock_calls) == 1 @@ -69,17 +69,21 @@ async def test_form(hass: HomeAssistant) -> None: result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], { - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 1.0, + CONF_LOCATION: { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.0, + } }, ) await hass.async_block_till_done() - assert result4["type"] == RESULT_TYPE_CREATE_ENTRY + assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["title"] == "Weather 1.0 1.0" assert result4["data"] == { - "latitude": 1.0, - "longitude": 1.0, + "location": { + "latitude": 1.0, + "longitude": 1.0, + }, "name": "Weather", } @@ -97,13 +101,15 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 0.0, - CONF_LONGITUDE: 0.0, + CONF_LOCATION: { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + } }, ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_FORM + assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "wrong_location"} # Continue flow with new coordinates @@ -117,17 +123,21 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 2.0, - CONF_LONGITUDE: 2.0, + CONF_LOCATION: { + CONF_LATITUDE: 2.0, + CONF_LONGITUDE: 2.0, + } }, ) await hass.async_block_till_done() - assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Weather 2.0 2.0" assert result3["data"] == { - "latitude": 2.0, - "longitude": 2.0, + "location": { + "latitude": 2.0, + "longitude": 2.0, + }, "name": "Weather", } @@ -138,8 +148,10 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: domain=DOMAIN, unique_id="1.0-1.0", data={ - "latitude": 1.0, - "longitude": 1.0, + "location": { + "latitude": 1.0, + "longitude": 1.0, + }, "name": "Weather", }, ) @@ -155,11 +167,13 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 1.0, + CONF_LOCATION: { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.0, + } }, ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_ABORT + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index 2cf54ba7533..ec6e4c417bb 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,10 +1,13 @@ """Test SMHI component setup process.""" +from unittest.mock import patch + from smhi.smhi_lib import APIURL_TEMPLATE from homeassistant.components.smhi.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get -from . import ENTITY_ID, TEST_CONFIG +from . import ENTITY_ID, TEST_CONFIG, TEST_CONFIG_MIGRATE from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -14,9 +17,11 @@ async def test_setup_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test setup entry.""" - uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -30,9 +35,11 @@ async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test remove entry.""" - uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -46,3 +53,60 @@ async def test_remove_entry( state = hass.states.get(ENTITY_ID) assert not state + + +async def test_migrate_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: + """Test migrate entry data.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry.add_to_hass(hass) + assert entry.version == 1 + + entity_reg = async_get(hass) + entity = entity_reg.async_get_or_create( + domain="weather", + config_entry=entry, + original_name="Weather", + platform="smhi", + supported_features=0, + unique_id="17.84197, 17.84197", + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state + + assert entry.version == 2 + assert entry.unique_id == "17.84197-17.84197" + + entity_get = entity_reg.async_get(entity.entity_id) + assert entity_get.unique_id == "17.84197, 17.84197" + + +async def test_migrate_entry_failed( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: + """Test migrate entry data that fails.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry.add_to_hass(hass) + assert entry.version == 1 + + with patch( + "homeassistant.config_entries.ConfigEntries.async_update_entry", + return_value=False, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index c890ad62216..f33e8c9fa71 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -16,18 +16,24 @@ from homeassistant.components.weather import ( ATTR_FORECAST, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, + DOMAIN as WEATHER_DOMAIN, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN +from homeassistant.const import ATTR_ATTRIBUTION, SPEED_METERS_PER_SECOND, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from . import ENTITY_ID, TEST_CONFIG @@ -40,10 +46,12 @@ async def test_setup_hass( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test for successfully setting up the smhi integration.""" - uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -58,13 +66,13 @@ async def test_setup_hass( assert state.state == "sunny" assert state.attributes[ATTR_SMHI_CLOUDINESS] == 50 assert state.attributes[ATTR_SMHI_THUNDER_PROBABILITY] == 33 - assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 17 + assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 16.92 assert state.attributes[ATTR_ATTRIBUTION].find("SMHI") >= 0 assert state.attributes[ATTR_WEATHER_HUMIDITY] == 55 assert state.attributes[ATTR_WEATHER_PRESSURE] == 1024 assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 17 assert state.attributes[ATTR_WEATHER_VISIBILITY] == 50 - assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 7 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 6.84 assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 134 assert len(state.attributes["forecast"]) == 4 @@ -74,11 +82,14 @@ async def test_setup_hass( assert forecast[ATTR_FORECAST_TEMP_LOW] == 6 assert forecast[ATTR_FORECAST_PRECIPITATION] == 0 assert forecast[ATTR_FORECAST_CONDITION] == "partlycloudy" + assert forecast[ATTR_FORECAST_PRESSURE] == 1026 + assert forecast[ATTR_FORECAST_WIND_BEARING] == 203 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 6.12 async def test_properties_no_data(hass: HomeAssistant) -> None: """Test properties when no API data available.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) with patch( @@ -167,7 +178,7 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: testdata = [data, data2, data3] - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) with patch( @@ -194,7 +205,7 @@ async def test_refresh_weather_forecast_retry( hass: HomeAssistant, error: Exception ) -> None: """Test the refresh weather forecast function.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) now = utcnow() @@ -305,3 +316,37 @@ def test_condition_class(): assert get_condition(23) == "snowy-rainy" # 24. Heavy sleet assert get_condition(24) == "snowy-rainy" + + +async def test_custom_speed_unit( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: + """Test Wind Gust speed with custom unit.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + + assert state + assert state.name == "test" + assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 16.92 + + entity_reg = er.async_get(hass) + entity_reg.async_update_entity_options( + state.entity_id, + WEATHER_DOMAIN, + {ATTR_WEATHER_WIND_SPEED_UNIT: SPEED_METERS_PER_SECOND}, + ) + + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 4.7 diff --git a/tests/components/snmp/__init__.py b/tests/components/snmp/__init__.py new file mode 100644 index 00000000000..e3890bb18fe --- /dev/null +++ b/tests/components/snmp/__init__.py @@ -0,0 +1 @@ +"""Tests for the SNMP integration.""" diff --git a/tests/components/snmp/test_sensor.py b/tests/components/snmp/test_sensor.py new file mode 100644 index 00000000000..9f3a555c2d9 --- /dev/null +++ b/tests/components/snmp/test_sensor.py @@ -0,0 +1,79 @@ +"""SNMP sensor tests.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +def hlapi_mock(): + """Mock out 3rd party API.""" + mock_data = MagicMock() + mock_data.prettyPrint = Mock(return_value="hello") + with patch( + "homeassistant.components.snmp.sensor.getCmd", + return_value=(None, None, None, [[mock_data]]), + ): + yield + + +async def test_basic_config(hass: HomeAssistant) -> None: + """Test basic entity configuration.""" + + config = { + SENSOR_DOMAIN: { + "platform": "snmp", + "host": "192.168.1.32", + "baseoid": "1.3.6.1.4.1.2021.10.1.3.1", + }, + } + + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.snmp") + assert state.state == "hello" + assert state.attributes == {"friendly_name": "SNMP"} + + +async def test_entity_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + SENSOR_DOMAIN: { + # SNMP configuration + "platform": "snmp", + "host": "192.168.1.32", + "baseoid": "1.3.6.1.4.1.2021.10.1.3.1", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "device_class": "temperature", + "name": "{{'SNMP' + ' ' + 'Sensor'}}", + "state_class": "measurement", + "unique_id": "very_unique", + "unit_of_measurement": "beardsecond", + }, + } + + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert entity_registry.async_get("sensor.snmp_sensor").unique_id == "very_unique" + + state = hass.states.get("sensor.snmp_sensor") + assert state.state == "hello" + assert state.attributes == { + "device_class": "temperature", + "entity_picture": "blabla.png", + "friendly_name": "SNMP Sensor", + "icon": "mdi:one_two_three", + "state_class": "measurement", + "unit_of_measurement": "beardsecond", + } diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index b3c9227648e..eb5d033f112 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -6,8 +6,9 @@ from homeassistant.components.solaredge.const import ( DEFAULT_NAME, DOMAIN, OVERVIEW_UPDATE_DELAY, + SENSOR_TYPES, ) -from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNAVAILABLE +from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -29,6 +30,9 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( ) mock_solaredge().get_details.return_value = {"details": {"status": "active"}} mock_config_entry.add_to_hass(hass) + for description in SENSOR_TYPES: + description.entity_registry_enabled_default = True + await hass.config_entries.async_setup(mock_config_entry.entry_id) # Valid energy values update @@ -56,7 +60,7 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( state = hass.states.get("sensor.solaredge_lifetime_energy") assert state - assert state.state == STATE_UNAVAILABLE + assert state.state == STATE_UNKNOWN # New valid energy values update mock_overview_data["overview"]["lifeTimeData"]["energy"] = 100001 @@ -74,9 +78,13 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) await hass.async_block_till_done() - state = hass.states.get("sensor.solaredge_lifetime_energy") + state = hass.states.get("sensor.solaredge_energy_this_year") assert state - assert state.state == STATE_UNAVAILABLE + assert state.state == STATE_UNKNOWN + # Check that the valid lastMonthData is still available + state = hass.states.get("sensor.solaredge_energy_this_month") + assert state + assert state.state == str(mock_overview_data["overview"]["lastMonthData"]["energy"]) # All zero energy values should also be valid. mock_overview_data["overview"]["lifeTimeData"]["energy"] = 0.0 diff --git a/tests/components/somfy/__init__.py b/tests/components/somfy/__init__.py deleted file mode 100644 index 05f5cbcf4f0..00000000000 --- a/tests/components/somfy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Somfy component.""" diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py deleted file mode 100644 index 752959802da..00000000000 --- a/tests/components/somfy/test_config_flow.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Tests for the Somfy config flow.""" -import asyncio -from http import HTTPStatus -from unittest.mock import patch - -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.somfy import DOMAIN -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.helpers import config_entry_oauth2_flow - -from tests.common import MockConfigEntry - -CLIENT_ID_VALUE = "1234" -CLIENT_SECRET_VALUE = "5678" - - -async def test_abort_if_no_configuration(hass): - """Check flow abort when no configuration.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" - - -async def test_abort_if_existing_entry(hass): - """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" - - -async def test_full_flow( - hass, hass_client_no_auth, aioclient_mock, current_request_with_host -): - """Check full flow.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_CLIENT_ID: CLIENT_ID_VALUE, - CONF_CLIENT_SECRET: CLIENT_SECRET_VALUE, - } - }, - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP - assert result["url"] == ( - "https://accounts.somfy.com/oauth/oauth/v2/auth" - f"?response_type=code&client_id={CLIENT_ID_VALUE}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - "https://accounts.somfy.com/oauth/oauth/v2/token", - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - - with patch( - "homeassistant.components.somfy.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["data"]["auth_implementation"] == DOMAIN - - result["data"]["token"].pop("expires_at") - assert result["data"]["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_abort_if_authorization_timeout(hass, current_request_with_host): - """Check Somfy authorization timeout.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_CLIENT_ID: CLIENT_ID_VALUE, - CONF_CLIENT_SECRET: CLIENT_SECRET_VALUE, - } - }, - ) - - with patch( - "homeassistant.components.somfy.config_entry_oauth2_flow." - "LocalOAuth2Implementation.async_generate_authorize_url", - side_effect=asyncio.TimeoutError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "authorize_url_timeout" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 8c804f466d4..f776fb62d58 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -108,6 +108,7 @@ def soco_fixture( mock_soco.mute = False mock_soco.night_mode = True mock_soco.dialog_level = True + mock_soco.loudness = True mock_soco.volume = 19 mock_soco.audio_delay = 2 mock_soco.bass = 1 @@ -116,6 +117,9 @@ def soco_fixture( mock_soco.sub_enabled = False mock_soco.sub_gain = 5 mock_soco.surround_enabled = True + mock_soco.surround_mode = True + mock_soco.surround_level = 3 + mock_soco.music_surround_level = 4 mock_soco.soundbar_audio_input_format = "Dolby 5.1" mock_soco.get_battery_info.return_value = battery_info mock_soco.all_zones = {mock_soco} diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index 5829a7a6724..83dcdf78ff8 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -22,6 +22,16 @@ async def test_number_entities(hass, async_autosetup_sonos, soco): audio_delay_state = hass.states.get(audio_delay_number.entity_id) assert audio_delay_state.state == "2" + surround_level_number = entity_registry.entities["number.zone_a_surround_level"] + surround_level_state = hass.states.get(surround_level_number.entity_id) + assert surround_level_state.state == "3" + + music_surround_level_number = entity_registry.entities[ + "number.zone_a_music_surround_level" + ] + music_surround_level_state = hass.states.get(music_surround_level_number.entity_id) + assert music_surround_level_state.state == "4" + with patch("soco.SoCo.audio_delay") as mock_audio_delay: await hass.services.async_call( NUMBER_DOMAIN, diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 96b3d222dc6..e47540a6aab 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -29,3 +29,21 @@ async def test_fallback_to_polling( assert speaker.subscriptions_failed assert "falling back to polling" in caplog.text assert "Activity on Zone A from SonosSpeaker.update_volume" in caplog.text + + +async def test_subscription_creation_fails(hass: HomeAssistant, async_setup_sonos): + """Test that subscription creation failures are handled.""" + with patch( + "homeassistant.components.sonos.speaker.SonosSpeaker._subscribe", + side_effect=ConnectionError("Took too long"), + ): + await async_setup_sonos() + + speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + assert not speaker._subscriptions + + with patch.object(speaker, "_resub_cooldown_expires_at", None): + speaker.speaker_activity("discovery") + await hass.async_block_till_done() + + assert speaker._subscriptions diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 586ccf213b8..2b794657565 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -29,6 +29,7 @@ async def test_entity_registry(hass, async_autosetup_sonos): assert "media_player.zone_a" in entity_registry.entities assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.zone_a_status_light" in entity_registry.entities + assert "switch.zone_a_loudness" in entity_registry.entities assert "switch.zone_a_night_sound" in entity_registry.entities assert "switch.zone_a_speech_enhancement" in entity_registry.entities assert "switch.zone_a_subwoofer_enabled" in entity_registry.entities @@ -51,10 +52,22 @@ async def test_switch_attributes(hass, async_autosetup_sonos, soco): assert alarm_state.attributes.get(ATTR_PLAY_MODE) == "SHUFFLE_NOREPEAT" assert not alarm_state.attributes.get(ATTR_INCLUDE_LINKED_ZONES) + surround_music_full_volume = entity_registry.entities[ + "switch.zone_a_surround_music_full_volume" + ] + surround_music_full_volume_state = hass.states.get( + surround_music_full_volume.entity_id + ) + assert surround_music_full_volume_state.state == STATE_ON + night_sound = entity_registry.entities["switch.zone_a_night_sound"] night_sound_state = hass.states.get(night_sound.entity_id) assert night_sound_state.state == STATE_ON + loudness = entity_registry.entities["switch.zone_a_loudness"] + loudness_state = hass.states.get(loudness.entity_id) + assert loudness_state.state == STATE_ON + speech_enhancement = entity_registry.entities["switch.zone_a_speech_enhancement"] speech_enhancement_state = hass.states.get(speech_enhancement.entity_id) assert speech_enhancement_state.state == STATE_ON diff --git a/tests/components/soundtouch/conftest.py b/tests/components/soundtouch/conftest.py new file mode 100644 index 00000000000..dcac360d253 --- /dev/null +++ b/tests/components/soundtouch/conftest.py @@ -0,0 +1,286 @@ +"""Fixtures for Bose SoundTouch integration tests.""" +import pytest +from requests_mock import Mocker + +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.components.soundtouch.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM + +from tests.common import load_fixture + +DEVICE_1_ID = "020000000001" +DEVICE_2_ID = "020000000002" +DEVICE_1_IP = "192.168.42.1" +DEVICE_2_IP = "192.168.42.2" +DEVICE_1_URL = f"http://{DEVICE_1_IP}:8090" +DEVICE_2_URL = f"http://{DEVICE_2_IP}:8090" +DEVICE_1_NAME = "My Soundtouch 1" +DEVICE_2_NAME = "My Soundtouch 2" +DEVICE_1_ENTITY_ID = f"{MEDIA_PLAYER_DOMAIN}.my_soundtouch_1" +DEVICE_2_ENTITY_ID = f"{MEDIA_PLAYER_DOMAIN}.my_soundtouch_2" + + +# pylint: disable=redefined-outer-name + + +@pytest.fixture +def device1_config() -> dict[str, str]: + """Mock SoundTouch device 1 config.""" + yield {CONF_PLATFORM: DOMAIN, CONF_HOST: DEVICE_1_IP, CONF_NAME: DEVICE_1_NAME} + + +@pytest.fixture +def device2_config() -> dict[str, str]: + """Mock SoundTouch device 2 config.""" + yield {CONF_PLATFORM: DOMAIN, CONF_HOST: DEVICE_2_IP, CONF_NAME: DEVICE_2_NAME} + + +@pytest.fixture(scope="session") +def device1_info() -> str: + """Load SoundTouch device 1 info response and return it.""" + return load_fixture("soundtouch/device1_info.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_aux() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_aux.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_bluetooth() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_bluetooth.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_radio() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_radio.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_standby() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_standby.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_upnp() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_upnp.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_upnp_paused() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_upnp_paused.xml") + + +@pytest.fixture(scope="session") +def device1_presets() -> str: + """Load SoundTouch device 1 presets response and return it.""" + return load_fixture("soundtouch/device1_presets.xml") + + +@pytest.fixture(scope="session") +def device1_volume() -> str: + """Load SoundTouch device 1 volume response and return it.""" + return load_fixture("soundtouch/device1_volume.xml") + + +@pytest.fixture(scope="session") +def device1_volume_muted() -> str: + """Load SoundTouch device 1 volume response and return it.""" + return load_fixture("soundtouch/device1_volume_muted.xml") + + +@pytest.fixture(scope="session") +def device1_zone_master() -> str: + """Load SoundTouch device 1 getZone response and return it.""" + return load_fixture("soundtouch/device1_getZone_master.xml") + + +@pytest.fixture(scope="session") +def device2_info() -> str: + """Load SoundTouch device 2 info response and return it.""" + return load_fixture("soundtouch/device2_info.xml") + + +@pytest.fixture(scope="session") +def device2_volume() -> str: + """Load SoundTouch device 2 volume response and return it.""" + return load_fixture("soundtouch/device2_volume.xml") + + +@pytest.fixture(scope="session") +def device2_now_playing_standby() -> str: + """Load SoundTouch device 2 now_playing response and return it.""" + return load_fixture("soundtouch/device2_now_playing_standby.xml") + + +@pytest.fixture(scope="session") +def device2_zone_slave() -> str: + """Load SoundTouch device 2 getZone response and return it.""" + return load_fixture("soundtouch/device2_getZone_slave.xml") + + +@pytest.fixture(scope="session") +def zone_empty() -> str: + """Load empty SoundTouch getZone response and return it.""" + return load_fixture("soundtouch/getZone_empty.xml") + + +@pytest.fixture +def device1_requests_mock( + requests_mock: Mocker, + device1_info: str, + device1_volume: str, + device1_presets: str, + device1_zone_master: str, +) -> Mocker: + """Mock SoundTouch device 1 API - base URLs.""" + requests_mock.get(f"{DEVICE_1_URL}/info", text=device1_info) + requests_mock.get(f"{DEVICE_1_URL}/volume", text=device1_volume) + requests_mock.get(f"{DEVICE_1_URL}/presets", text=device1_presets) + requests_mock.get(f"{DEVICE_1_URL}/getZone", text=device1_zone_master) + yield requests_mock + + +@pytest.fixture +def device1_requests_mock_standby( + device1_requests_mock: Mocker, + device1_now_playing_standby: str, +): + """Mock SoundTouch device 1 API - standby.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_standby + ) + + +@pytest.fixture +def device1_requests_mock_aux( + device1_requests_mock: Mocker, + device1_now_playing_aux: str, +): + """Mock SoundTouch device 1 API - playing AUX.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_aux + ) + + +@pytest.fixture +def device1_requests_mock_bluetooth( + device1_requests_mock: Mocker, + device1_now_playing_bluetooth: str, +): + """Mock SoundTouch device 1 API - playing bluetooth.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_bluetooth + ) + + +@pytest.fixture +def device1_requests_mock_radio( + device1_requests_mock: Mocker, + device1_now_playing_radio: str, +): + """Mock SoundTouch device 1 API - playing radio.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_radio + ) + + +@pytest.fixture +def device1_requests_mock_upnp( + device1_requests_mock: Mocker, + device1_now_playing_upnp: str, +): + """Mock SoundTouch device 1 API - playing UPNP.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_upnp + ) + + +@pytest.fixture +def device1_requests_mock_upnp_paused( + device1_requests_mock: Mocker, + device1_now_playing_upnp_paused: str, +): + """Mock SoundTouch device 1 API - playing UPNP (paused).""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_upnp_paused + ) + + +@pytest.fixture +def device1_requests_mock_key( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - key endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/key") + + +@pytest.fixture +def device1_requests_mock_volume( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - volume endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/volume") + + +@pytest.fixture +def device1_requests_mock_select( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - select endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/select") + + +@pytest.fixture +def device1_requests_mock_set_zone( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - setZone endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/setZone") + + +@pytest.fixture +def device1_requests_mock_add_zone_slave( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - addZoneSlave endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/addZoneSlave") + + +@pytest.fixture +def device1_requests_mock_remove_zone_slave( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - removeZoneSlave endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/removeZoneSlave") + + +@pytest.fixture +def device1_requests_mock_dlna( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - DLNA endpoint.""" + yield device1_requests_mock.post(f"http://{DEVICE_1_IP}:8091/AVTransport/Control") + + +@pytest.fixture +def device2_requests_mock_standby( + requests_mock: Mocker, + device2_info: str, + device2_volume: str, + device2_now_playing_standby: str, + device2_zone_slave: str, +) -> Mocker: + """Mock SoundTouch device 2 API.""" + requests_mock.get(f"{DEVICE_2_URL}/info", text=device2_info) + requests_mock.get(f"{DEVICE_2_URL}/volume", text=device2_volume) + requests_mock.get(f"{DEVICE_2_URL}/now_playing", text=device2_now_playing_standby) + requests_mock.get(f"{DEVICE_2_URL}/getZone", text=device2_zone_slave) + + yield requests_mock diff --git a/tests/components/soundtouch/fixtures/device1_getZone_master.xml b/tests/components/soundtouch/fixtures/device1_getZone_master.xml new file mode 100644 index 00000000000..f4b0fd05a51 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_getZone_master.xml @@ -0,0 +1,6 @@ + + + 020000000001 + 020000000002 + 020000000003 + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_info.xml b/tests/components/soundtouch/fixtures/device1_info.xml new file mode 100644 index 00000000000..27878969ca0 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_info.xml @@ -0,0 +1,32 @@ + + + My SoundTouch 1 + SoundTouch 10 + 0 + + + SCM + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + P0000000000000000000001 + + + PackagedProduct + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + 000000P00000001AE + + + https://streaming.bose.com + + 020000000001 + 192.168.42.1 + + + 060000000001 + 192.168.42.1 + + sm2 + rhino + normal + US + US + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_aux.xml b/tests/components/soundtouch/fixtures/device1_now_playing_aux.xml new file mode 100644 index 00000000000..e19dc1dd954 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_aux.xml @@ -0,0 +1,7 @@ + + + + AUX IN + + PLAY_STATE + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_bluetooth.xml b/tests/components/soundtouch/fixtures/device1_now_playing_bluetooth.xml new file mode 100644 index 00000000000..c43fe187f2f --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_bluetooth.xml @@ -0,0 +1,16 @@ + + + + MockPairedBluetoothDevice + + MockTrack + MockArtist + MockAlbum + MockPairedBluetoothDevice + + + PLAY_STATE + + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_radio.xml b/tests/components/soundtouch/fixtures/device1_now_playing_radio.xml new file mode 100644 index 00000000000..b9d47216b3a --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_radio.xml @@ -0,0 +1,16 @@ + + + + MockStation + http://cdn-profiles.tunein.com/sXXXXX/images/logoq.png + + MockTrack + MockArtist + MockAlbum + MockStation + http://cdn-profiles.tunein.com/sXXXXX/images/logoq.png + + PLAY_STATE + RADIO_STREAMING + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_standby.xml b/tests/components/soundtouch/fixtures/device1_now_playing_standby.xml new file mode 100644 index 00000000000..67acae6a0ef --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_standby.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_upnp.xml b/tests/components/soundtouch/fixtures/device1_now_playing_upnp.xml new file mode 100644 index 00000000000..e58e62072ce --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_upnp.xml @@ -0,0 +1,13 @@ + + + + MockTrack + MockArtist + MockAlbum + + + + PLAY_STATE + + TRACK_ONDEMAND + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_upnp_paused.xml b/tests/components/soundtouch/fixtures/device1_now_playing_upnp_paused.xml new file mode 100644 index 00000000000..6275ada6e4b --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_upnp_paused.xml @@ -0,0 +1,13 @@ + + + + MockTrack + MockArtist + MockAlbum + + + + PAUSE_STATE + + TRACK_ONDEMAND + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_presets.xml b/tests/components/soundtouch/fixtures/device1_presets.xml new file mode 100644 index 00000000000..6bacfa48732 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_presets.xml @@ -0,0 +1,12 @@ + + + + + + + + MockStation + http://cdn-profiles.tunein.com/sXXXXX/images/logoq.png + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_volume.xml b/tests/components/soundtouch/fixtures/device1_volume.xml new file mode 100644 index 00000000000..cef90efa37d --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_volume.xml @@ -0,0 +1,6 @@ + + + 12 + 12 + false + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_volume_muted.xml b/tests/components/soundtouch/fixtures/device1_volume_muted.xml new file mode 100644 index 00000000000..e26fbd55e08 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_volume_muted.xml @@ -0,0 +1,6 @@ + + + 12 + 12 + true + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_getZone_slave.xml b/tests/components/soundtouch/fixtures/device2_getZone_slave.xml new file mode 100644 index 00000000000..fa9db0bf748 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_getZone_slave.xml @@ -0,0 +1,4 @@ + + + 020000000002 + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_info.xml b/tests/components/soundtouch/fixtures/device2_info.xml new file mode 100644 index 00000000000..a93a19fb52a --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_info.xml @@ -0,0 +1,32 @@ + + + My SoundTouch 2 + SoundTouch 10 + 0 + + + SCM + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + P0000000000000000000002 + + + PackagedProduct + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + 000000P00000002AE + + + https://streaming.bose.com + + 020000000002 + 192.168.42.2 + + + 060000000002 + 192.168.42.2 + + sm2 + rhino + normal + US + US + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_now_playing_standby.xml b/tests/components/soundtouch/fixtures/device2_now_playing_standby.xml new file mode 100644 index 00000000000..1b8bf8a5a3c --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_now_playing_standby.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_volume.xml b/tests/components/soundtouch/fixtures/device2_volume.xml new file mode 100644 index 00000000000..436bd888980 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_volume.xml @@ -0,0 +1,6 @@ + + + 10 + 10 + false + \ No newline at end of file diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index 797b5b440d1..1b16508bb88 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -1,1062 +1,686 @@ -"""Test the Soundtouch component.""" -from unittest.mock import call, patch +"""Test the SoundTouch component.""" +from typing import Any -from libsoundtouch.device import ( - Config, - Preset, - SoundTouchDevice as STD, - Status, - Volume, - ZoneSlave, - ZoneStatus, -) -import pytest +from requests_mock import Mocker from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, +) +from homeassistant.components.soundtouch.const import ( + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, + SERVICE_CREATE_ZONE, + SERVICE_PLAY_EVERYWHERE, + SERVICE_REMOVE_ZONE_SLAVE, ) -from homeassistant.components.soundtouch import media_player as soundtouch -from homeassistant.components.soundtouch.const import DOMAIN from homeassistant.components.soundtouch.media_player import ( ATTR_SOUNDTOUCH_GROUP, ATTR_SOUNDTOUCH_ZONE, DATA_SOUNDTOUCH, ) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -# pylint: disable=super-init-not-called +from .conftest import DEVICE_1_ENTITY_ID, DEVICE_2_ENTITY_ID -DEVICE_1_IP = "192.168.0.1" -DEVICE_2_IP = "192.168.0.2" -DEVICE_1_ID = 1 -DEVICE_2_ID = 2 - - -def get_config(host=DEVICE_1_IP, port=8090, name="soundtouch"): - """Return a default component.""" - return {"platform": DOMAIN, "host": host, "port": port, "name": name} - - -DEVICE_1_CONFIG = {**get_config(), "name": "soundtouch_1"} -DEVICE_2_CONFIG = {**get_config(), "host": DEVICE_2_IP, "name": "soundtouch_2"} - - -@pytest.fixture(name="one_device") -def one_device_fixture(): - """Mock one master device.""" - device_1 = MockDevice() - device_patch = patch( - "homeassistant.components.soundtouch.media_player.soundtouch_device", - return_value=device_1, +async def setup_soundtouch(hass: HomeAssistant, *configs: dict[str, str]): + """Initialize media_player for tests.""" + assert await async_setup_component( + hass, MEDIA_PLAYER_DOMAIN, {MEDIA_PLAYER_DOMAIN: list(configs)} ) - with device_patch as device: - yield device - - -@pytest.fixture(name="two_zones") -def two_zones_fixture(): - """Mock one master and one slave.""" - device_1 = MockDevice( - DEVICE_1_ID, - MockZoneStatus( - is_master=True, - master_id=DEVICE_1_ID, - master_ip=DEVICE_1_IP, - slaves=[MockZoneSlave(DEVICE_2_IP)], - ), - ) - device_2 = MockDevice( - DEVICE_2_ID, - MockZoneStatus( - is_master=False, - master_id=DEVICE_1_ID, - master_ip=DEVICE_1_IP, - slaves=[MockZoneSlave(DEVICE_2_IP)], - ), - ) - devices = {DEVICE_1_IP: device_1, DEVICE_2_IP: device_2} - device_patch = patch( - "homeassistant.components.soundtouch.media_player.soundtouch_device", - side_effect=lambda host, _: devices[host], - ) - with device_patch as device: - yield device - - -@pytest.fixture(name="mocked_status") -def status_fixture(): - """Mock the device status.""" - status_patch = patch( - "libsoundtouch.device.SoundTouchDevice.status", side_effect=MockStatusPlaying - ) - with status_patch as status: - yield status - - -@pytest.fixture(name="mocked_volume") -def volume_fixture(): - """Mock the device volume.""" - volume_patch = patch("libsoundtouch.device.SoundTouchDevice.volume") - with volume_patch as volume: - yield volume - - -async def setup_soundtouch(hass, config): - """Set up soundtouch integration.""" - assert await async_setup_component(hass, "media_player", {"media_player": config}) await hass.async_block_till_done() await hass.async_start() -class MockDevice(STD): - """Mock device.""" - - def __init__(self, id=None, zone_status=None): - """Init the class.""" - self._config = MockConfig(id) - self._zone_status = zone_status or MockZoneStatus() - - def zone_status(self, refresh=True): - """Zone status mock object.""" - return self._zone_status - - -class MockConfig(Config): - """Mock config.""" - - def __init__(self, id=None): - """Init class.""" - self._name = "name" - self._id = id or DEVICE_1_ID - - -class MockZoneStatus(ZoneStatus): - """Mock zone status.""" - - def __init__(self, is_master=True, master_id=None, master_ip=None, slaves=None): - """Init the class.""" - self._is_master = is_master - self._master_id = master_id - self._master_ip = master_ip - self._slaves = slaves or [] - - -class MockZoneSlave(ZoneSlave): - """Mock zone slave.""" - - def __init__(self, device_ip=None, role=None): - """Init the class.""" - self._ip = device_ip - self._role = role - - -def _mocked_presets(*args, **kwargs): - """Return a list of mocked presets.""" - return [MockPreset("1")] - - -class MockPreset(Preset): - """Mock preset.""" - - def __init__(self, id_): - """Init the class.""" - self._id = id_ - self._name = "preset" - - -class MockVolume(Volume): - """Mock volume with value.""" - - def __init__(self): - """Init class.""" - self._actual = 12 - self._muted = False - - -class MockVolumeMuted(Volume): - """Mock volume muted.""" - - def __init__(self): - """Init the class.""" - self._actual = 12 - self._muted = True - - -class MockStatusStandby(Status): - """Mock status standby.""" - - def __init__(self): - """Init the class.""" - self._source = "STANDBY" - - -class MockStatusPlaying(Status): - """Mock status playing media.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = "artist" - self._track = "track" - self._album = "album" - self._duration = 1 - self._station_name = None - - -class MockStatusPlayingRadio(Status): - """Mock status radio.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = "station" - - -class MockStatusUnknown(Status): - """Mock status unknown media.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = None - - -class MockStatusPause(Status): - """Mock status pause.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PAUSE_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = None - - -class MockStatusPlayingAux(Status): - """Mock status AUX.""" - - def __init__(self): - """Init the class.""" - self._source = "AUX" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = None - - -class MockStatusPlayingBluetooth(Status): - """Mock status Bluetooth.""" - - def __init__(self): - """Init the class.""" - self._source = "BLUETOOTH" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = "artist" - self._track = "track" - self._album = "album" - self._duration = None - self._station_name = None - - -async def test_ensure_setup_config(mocked_status, mocked_volume, hass, one_device): - """Test setup OK with custom config.""" - await setup_soundtouch( - hass, get_config(host="192.168.1.44", port=8888, name="custom_sound") - ) - - assert one_device.call_count == 1 - assert one_device.call_args == call("192.168.1.44", 8888) - assert len(hass.states.async_all()) == 1 - state = hass.states.get("media_player.custom_sound") - assert state.name == "custom_sound" - - -async def test_ensure_setup_discovery(mocked_status, mocked_volume, hass, one_device): - """Test setup with discovery.""" - new_device = { - "port": "8090", - "host": "192.168.1.1", - "properties": {}, - "hostname": "hostname.local", - } - await async_load_platform( - hass, "media_player", DOMAIN, new_device, {"media_player": {}} - ) - await hass.async_block_till_done() - - assert one_device.call_count == 1 - assert one_device.call_args == call("192.168.1.1", 8090) - assert len(hass.states.async_all()) == 1 - - -async def test_ensure_setup_discovery_no_duplicate( - mocked_status, mocked_volume, hass, one_device +async def _test_key_service( + hass: HomeAssistant, + requests_mock_key, + service: str, + service_data: dict[str, Any], + key_name: str, ): - """Test setup OK if device already exists.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert len(hass.states.async_all()) == 1 - - new_device = { - "port": "8090", - "host": "192.168.1.1", - "properties": {}, - "hostname": "hostname.local", - } - await async_load_platform( - hass, "media_player", DOMAIN, new_device, {"media_player": DEVICE_1_CONFIG} - ) - await hass.async_block_till_done() - assert one_device.call_count == 2 - assert len(hass.states.async_all()) == 2 - - existing_device = { - "port": "8090", - "host": "192.168.0.1", - "properties": {}, - "hostname": "hostname.local", - } - await async_load_platform( - hass, "media_player", DOMAIN, existing_device, {"media_player": DEVICE_1_CONFIG} - ) - await hass.async_block_till_done() - assert one_device.call_count == 2 - assert len(hass.states.async_all()) == 2 + """Test API calls that use the /key endpoint to emulate physical button clicks.""" + requests_mock_key.reset() + await hass.services.async_call("media_player", service, service_data, True) + assert requests_mock_key.call_count == 2 + assert f">{key_name}" in requests_mock_key.last_request.text -async def test_playing_media(mocked_status, mocked_volume, hass, one_device): +async def test_playing_media( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, +): """Test playing media info.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["media_title"] == "artist - track" - assert entity_1_state.attributes["media_track"] == "track" - assert entity_1_state.attributes["media_artist"] == "artist" - assert entity_1_state.attributes["media_album_name"] == "album" - assert entity_1_state.attributes["media_duration"] == 1 + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_MEDIA_TITLE] == "MockArtist - MockTrack" + assert entity_state.attributes[ATTR_MEDIA_TRACK] == "MockTrack" + assert entity_state.attributes[ATTR_MEDIA_ARTIST] == "MockArtist" + assert entity_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "MockAlbum" + assert entity_state.attributes[ATTR_MEDIA_DURATION] == 42 -async def test_playing_unknown_media(mocked_status, mocked_volume, hass, one_device): - """Test playing media info.""" - mocked_status.side_effect = MockStatusUnknown - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - - -async def test_playing_radio(mocked_status, mocked_volume, hass, one_device): +async def test_playing_radio( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_radio, +): """Test playing radio info.""" - mocked_status.side_effect = MockStatusPlayingRadio - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["media_title"] == "station" + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_MEDIA_TITLE] == "MockStation" -async def test_playing_aux(mocked_status, mocked_volume, hass, one_device): +async def test_playing_aux( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_aux, +): """Test playing AUX info.""" - mocked_status.side_effect = MockStatusPlayingAux - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["source"] == "AUX" + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_INPUT_SOURCE] == "AUX" -async def test_playing_bluetooth(mocked_status, mocked_volume, hass, one_device): +async def test_playing_bluetooth( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_bluetooth, +): """Test playing Bluetooth info.""" - mocked_status.side_effect = MockStatusPlayingBluetooth - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["source"] == "BLUETOOTH" - assert entity_1_state.attributes["media_track"] == "track" - assert entity_1_state.attributes["media_artist"] == "artist" - assert entity_1_state.attributes["media_album_name"] == "album" + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_INPUT_SOURCE] == "BLUETOOTH" + assert entity_state.attributes[ATTR_MEDIA_TRACK] == "MockTrack" + assert entity_state.attributes[ATTR_MEDIA_ARTIST] == "MockArtist" + assert entity_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "MockAlbum" -async def test_get_volume_level(mocked_status, mocked_volume, hass, one_device): +async def test_get_volume_level( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, +): """Test volume level.""" - mocked_volume.side_effect = MockVolume - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.attributes["volume_level"] == 0.12 + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.attributes["volume_level"] == 0.12 -async def test_get_state_off(mocked_status, mocked_volume, hass, one_device): +async def test_get_state_off( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, +): """Test state device is off.""" - mocked_status.side_effect = MockStatusStandby - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_OFF + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_OFF -async def test_get_state_pause(mocked_status, mocked_volume, hass, one_device): +async def test_get_state_pause( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp_paused, +): """Test state device is paused.""" - mocked_status.side_effect = MockStatusPause - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PAUSED + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PAUSED -async def test_is_muted(mocked_status, mocked_volume, hass, one_device): +async def test_is_muted( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_volume_muted: str, +): """Test device volume is muted.""" - mocked_volume.side_effect = MockVolumeMuted - await setup_soundtouch(hass, DEVICE_1_CONFIG) + with Mocker(real_http=True) as mocker: + mocker.get("/volume", text=device1_volume_muted) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.attributes["is_volume_muted"] + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.attributes[ATTR_MEDIA_VOLUME_MUTED] -async def test_media_commands(mocked_status, mocked_volume, hass, one_device): - """Test supported media commands.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.attributes["supported_features"] == 151485 - - -@patch("libsoundtouch.device.SoundTouchDevice.power_off") async def test_should_turn_off( - mocked_power_off, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test device is turned off.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "turn_off", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "POWER", ) - assert mocked_status.call_count == 3 - assert mocked_power_off.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.power_on") async def test_should_turn_on( - mocked_power_on, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_key, ): """Test device is turned on.""" - mocked_status.side_effect = MockStatusStandby - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "turn_on", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "POWER", ) - assert mocked_status.call_count == 3 - assert mocked_power_on.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.volume_up") async def test_volume_up( - mocked_volume_up, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test volume up.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "volume_up", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "VOLUME_UP", ) - assert mocked_volume.call_count == 3 - assert mocked_volume_up.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.volume_down") async def test_volume_down( - mocked_volume_down, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test volume down.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "volume_down", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "VOLUME_DOWN", ) - assert mocked_volume.call_count == 3 - assert mocked_volume_down.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.set_volume") async def test_set_volume_level( - mocked_set_volume, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_volume, ): """Test set volume level.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) + assert device1_requests_mock_volume.call_count == 0 await hass.services.async_call( "media_player", "volume_set", - {"entity_id": "media_player.soundtouch_1", "volume_level": 0.17}, + {"entity_id": DEVICE_1_ENTITY_ID, "volume_level": 0.17}, True, ) - assert mocked_volume.call_count == 3 - mocked_set_volume.assert_called_with(17) + assert device1_requests_mock_volume.call_count == 1 + assert "17" in device1_requests_mock_volume.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.mute") -async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device): +async def test_mute( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, +): """Test mute volume.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "volume_mute", - {"entity_id": "media_player.soundtouch_1", "is_volume_muted": True}, - True, + {"entity_id": DEVICE_1_ENTITY_ID, "is_volume_muted": True}, + "MUTE", ) - assert mocked_volume.call_count == 3 - assert mocked_mute.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.play") -async def test_play(mocked_play, mocked_status, mocked_volume, hass, one_device): +async def test_play( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp_paused, + device1_requests_mock_key, +): """Test play command.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_play", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PLAY", ) - assert mocked_status.call_count == 3 - assert mocked_play.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.pause") -async def test_pause(mocked_pause, mocked_status, mocked_volume, hass, one_device): +async def test_pause( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, +): """Test pause command.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_pause", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PAUSE", ) - assert mocked_status.call_count == 3 - assert mocked_pause.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.play_pause") async def test_play_pause( - mocked_play_pause, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test play/pause.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_play_pause", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PLAY_PAUSE", ) - assert mocked_status.call_count == 3 - assert mocked_play_pause.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.previous_track") -@patch("libsoundtouch.device.SoundTouchDevice.next_track") async def test_next_previous_track( - mocked_next_track, - mocked_previous_track, - mocked_status, - mocked_volume, - hass, - one_device, + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test next/previous track.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_next_track", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "NEXT_TRACK", ) - assert mocked_status.call_count == 3 - assert mocked_next_track.call_count == 1 - await hass.services.async_call( - "media_player", + await _test_key_service( + hass, + device1_requests_mock_key, "media_previous_track", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PREV_TRACK", ) - assert mocked_status.call_count == 4 - assert mocked_previous_track.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.select_preset") -@patch("libsoundtouch.device.SoundTouchDevice.presets", side_effect=_mocked_presets) async def test_play_media( - mocked_presets, mocked_select_preset, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test play preset 1.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) + assert device1_requests_mock_select.call_count == 0 await hass.services.async_call( "media_player", "play_media", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_MEDIA_CONTENT_TYPE: "PLAYLIST", ATTR_MEDIA_CONTENT_ID: 1, }, True, ) - assert mocked_presets.call_count == 1 - assert mocked_select_preset.call_count == 1 + assert device1_requests_mock_select.call_count == 1 + assert ( + 'location="http://homeassistant:8123/media/local/test.mp3"' + in device1_requests_mock_select.last_request.text + ) await hass.services.async_call( "media_player", "play_media", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_MEDIA_CONTENT_TYPE: "PLAYLIST", ATTR_MEDIA_CONTENT_ID: 2, }, True, ) - assert mocked_presets.call_count == 2 - assert mocked_select_preset.call_count == 1 + assert device1_requests_mock_select.call_count == 2 + assert "MockStation" in device1_requests_mock_select.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.play_url") async def test_play_media_url( - mocked_play_url, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_dlna, ): """Test play preset 1.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) + assert device1_requests_mock_dlna.call_count == 0 await hass.services.async_call( "media_player", "play_media", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_MEDIA_CONTENT_TYPE: "MUSIC", ATTR_MEDIA_CONTENT_ID: "http://fqdn/file.mp3", }, True, ) - mocked_play_url.assert_called_with("http://fqdn/file.mp3") + assert device1_requests_mock_dlna.call_count == 1 + assert "http://fqdn/file.mp3" in device1_requests_mock_dlna.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.select_source_aux") async def test_select_source_aux( - mocked_select_source_aux, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test select AUX.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert mocked_select_source_aux.call_count == 0 + assert device1_requests_mock_select.call_count == 0 await hass.services.async_call( "media_player", "select_source", - {"entity_id": "media_player.soundtouch_1", ATTR_INPUT_SOURCE: "AUX"}, + {"entity_id": DEVICE_1_ENTITY_ID, ATTR_INPUT_SOURCE: "AUX"}, True, ) - - assert mocked_select_source_aux.call_count == 1 + assert device1_requests_mock_select.call_count == 1 + assert "AUX" in device1_requests_mock_select.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.select_source_bluetooth") async def test_select_source_bluetooth( - mocked_select_source_bluetooth, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test select Bluetooth.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert mocked_select_source_bluetooth.call_count == 0 + assert device1_requests_mock_select.call_count == 0 await hass.services.async_call( "media_player", "select_source", - {"entity_id": "media_player.soundtouch_1", ATTR_INPUT_SOURCE: "BLUETOOTH"}, + {"entity_id": DEVICE_1_ENTITY_ID, ATTR_INPUT_SOURCE: "BLUETOOTH"}, True, ) - - assert mocked_select_source_bluetooth.call_count == 1 + assert device1_requests_mock_select.call_count == 1 + assert "BLUETOOTH" in device1_requests_mock_select.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.select_source_bluetooth") -@patch("libsoundtouch.device.SoundTouchDevice.select_source_aux") async def test_select_source_invalid_source( - mocked_select_source_aux, - mocked_select_source_bluetooth, - mocked_status, - mocked_volume, - hass, - one_device, + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test select unsupported source.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert mocked_select_source_aux.call_count == 0 - assert mocked_select_source_bluetooth.call_count == 0 + await setup_soundtouch(hass, device1_config) + assert not device1_requests_mock_select.called await hass.services.async_call( "media_player", "select_source", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_INPUT_SOURCE: "SOMETHING_UNSUPPORTED", }, True, ) - - assert mocked_select_source_aux.call_count == 0 - assert mocked_select_source_bluetooth.call_count == 0 + assert not device1_requests_mock_select.called -@patch("libsoundtouch.device.SoundTouchDevice.create_zone") async def test_play_everywhere( - mocked_create_zone, mocked_status, mocked_volume, hass, two_zones + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_set_zone, ): """Test play everywhere.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + await setup_soundtouch(hass, device1_config, device2_config) - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 - - # one master, one slave => create zone + # one master, one slave => set zone await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_PLAY_EVERYWHERE, - {"master": "media_player.soundtouch_1"}, + DOMAIN, + SERVICE_PLAY_EVERYWHERE, + {"master": DEVICE_1_ENTITY_ID}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # unknown master, create zone must not be called + # unknown master, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_PLAY_EVERYWHERE, + DOMAIN, + SERVICE_PLAY_EVERYWHERE, {"master": "media_player.entity_X"}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # no slaves, create zone must not be called + # remove second device for entity in list(hass.data[DATA_SOUNDTOUCH]): - if entity.entity_id == "media_player.soundtouch_1": + if entity.entity_id == DEVICE_1_ENTITY_ID: continue hass.data[DATA_SOUNDTOUCH].remove(entity) await entity.async_remove() + + # no slaves, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_PLAY_EVERYWHERE, - {"master": "media_player.soundtouch_1"}, + DOMAIN, + SERVICE_PLAY_EVERYWHERE, + {"master": DEVICE_1_ENTITY_ID}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.create_zone") async def test_create_zone( - mocked_create_zone, mocked_status, mocked_volume, hass, two_zones + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_set_zone, ): """Test creating a zone.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + await setup_soundtouch(hass, device1_config, device2_config) - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 + assert device1_requests_mock_set_zone.call_count == 0 - # one master, one slave => create zone + # one master, one slave => set zone await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_CREATE_ZONE, + DOMAIN, + SERVICE_CREATE_ZONE, { - "master": "media_player.soundtouch_1", - "slaves": ["media_player.soundtouch_2"], + "master": DEVICE_1_ENTITY_ID, + "slaves": [DEVICE_2_ENTITY_ID], }, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # unknown master, create zone must not be called + # unknown master, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_CREATE_ZONE, - {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]}, + DOMAIN, + SERVICE_CREATE_ZONE, + {"master": "media_player.entity_X", "slaves": [DEVICE_2_ENTITY_ID]}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # no slaves, create zone must not be called + # no slaves, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_CREATE_ZONE, - {"master": "media_player.soundtouch_1", "slaves": []}, + DOMAIN, + SERVICE_CREATE_ZONE, + {"master": DEVICE_1_ENTITY_ID, "slaves": []}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.remove_zone_slave") async def test_remove_zone_slave( - mocked_remove_zone_slave, mocked_status, mocked_volume, hass, two_zones + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_remove_zone_slave, ): - """Test adding a slave to an existing zone.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) - - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 + """Test removing a slave from an existing zone.""" + await setup_soundtouch(hass, device1_config, device2_config) # remove one slave await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_REMOVE_ZONE_SLAVE, + DOMAIN, + SERVICE_REMOVE_ZONE_SLAVE, { - "master": "media_player.soundtouch_1", - "slaves": ["media_player.soundtouch_2"], + "master": DEVICE_1_ENTITY_ID, + "slaves": [DEVICE_2_ENTITY_ID], }, True, ) - assert mocked_remove_zone_slave.call_count == 1 + assert device1_requests_mock_remove_zone_slave.call_count == 1 - # unknown master. add zone slave is not called + # unknown master, remove zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_REMOVE_ZONE_SLAVE, - {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]}, + DOMAIN, + SERVICE_REMOVE_ZONE_SLAVE, + {"master": "media_player.entity_X", "slaves": [DEVICE_2_ENTITY_ID]}, True, ) - assert mocked_remove_zone_slave.call_count == 1 + assert device1_requests_mock_remove_zone_slave.call_count == 1 - # no slave to add, add zone slave is not called + # no slave to remove, remove zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_REMOVE_ZONE_SLAVE, - {"master": "media_player.soundtouch_1", "slaves": []}, + DOMAIN, + SERVICE_REMOVE_ZONE_SLAVE, + {"master": DEVICE_1_ENTITY_ID, "slaves": []}, True, ) - assert mocked_remove_zone_slave.call_count == 1 + assert device1_requests_mock_remove_zone_slave.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.add_zone_slave") async def test_add_zone_slave( - mocked_add_zone_slave, - mocked_status, - mocked_volume, - hass, - two_zones, + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_add_zone_slave, ): - """Test removing a slave from a zone.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) - - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 + """Test adding a slave to a zone.""" + await setup_soundtouch(hass, device1_config, device2_config) # add one slave await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_ADD_ZONE_SLAVE, + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, { - "master": "media_player.soundtouch_1", - "slaves": ["media_player.soundtouch_2"], + "master": DEVICE_1_ENTITY_ID, + "slaves": [DEVICE_2_ENTITY_ID], }, True, ) - assert mocked_add_zone_slave.call_count == 1 + assert device1_requests_mock_add_zone_slave.call_count == 1 # unknown master, add zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_ADD_ZONE_SLAVE, - {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]}, + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, + {"master": "media_player.entity_X", "slaves": [DEVICE_2_ENTITY_ID]}, True, ) - assert mocked_add_zone_slave.call_count == 1 + assert device1_requests_mock_add_zone_slave.call_count == 1 # no slave to add, add zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_ADD_ZONE_SLAVE, - {"master": "media_player.soundtouch_1", "slaves": ["media_player.entity_X"]}, + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, + {"master": DEVICE_1_ENTITY_ID, "slaves": ["media_player.entity_X"]}, True, ) - assert mocked_add_zone_slave.call_count == 1 + assert device1_requests_mock_add_zone_slave.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.create_zone") async def test_zone_attributes( - mocked_create_zone, - mocked_status, - mocked_volume, - hass, - two_zones, + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, ): - """Test play everywhere.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + """Test zone attributes.""" + await setup_soundtouch(hass, device1_config, device2_config) - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 - - entity_1_state = hass.states.get("media_player.soundtouch_1") + entity_1_state = hass.states.get(DEVICE_1_ENTITY_ID) assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] assert ( - entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] - == "media_player.soundtouch_1" + entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] == DEVICE_1_ENTITY_ID ) assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ - "media_player.soundtouch_2" + DEVICE_2_ENTITY_ID ] assert entity_1_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ - "media_player.soundtouch_1", - "media_player.soundtouch_2", + DEVICE_1_ENTITY_ID, + DEVICE_2_ENTITY_ID, ] - entity_2_state = hass.states.get("media_player.soundtouch_2") + + entity_2_state = hass.states.get(DEVICE_2_ENTITY_ID) assert not entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] assert ( - entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] - == "media_player.soundtouch_1" + entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] == DEVICE_1_ENTITY_ID ) assert entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ - "media_player.soundtouch_2" + DEVICE_2_ENTITY_ID ] assert entity_2_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ - "media_player.soundtouch_1", - "media_player.soundtouch_2", + DEVICE_1_ENTITY_ID, + DEVICE_2_ENTITY_ID, ] diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index a3d2da8bd52..91b4106c1f4 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -12,7 +12,6 @@ so that it can inspect the output. from __future__ import annotations import asyncio -from collections import deque from http import HTTPStatus import logging import threading @@ -20,10 +19,9 @@ from typing import Generator from unittest.mock import Mock, patch from aiohttp import web -import async_timeout import pytest -from homeassistant.components.stream.core import Segment, StreamOutput +from homeassistant.components.stream.core import StreamOutput from homeassistant.components.stream.worker import StreamState from .common import generate_h264_video, stream_teardown @@ -73,61 +71,6 @@ def stream_worker_sync(hass): yield sync -class SaveRecordWorkerSync: - """ - Test fixture to manage RecordOutput thread for recorder_save_worker. - - This is used to assert that the worker is started and stopped cleanly - to avoid thread leaks in tests. - """ - - def __init__(self, hass): - """Initialize SaveRecordWorkerSync.""" - self._hass = hass - self._save_event = None - self._segments = None - self._save_thread = None - self.reset() - - def recorder_save_worker(self, file_out: str, segments: deque[Segment]): - """Mock method for patch.""" - logging.debug("recorder_save_worker thread started") - assert self._save_thread is None - self._segments = segments - self._save_thread = threading.current_thread() - self._hass.loop.call_soon_threadsafe(self._save_event.set) - - async def get_segments(self): - """Return the recorded video segments.""" - async with async_timeout.timeout(TEST_TIMEOUT): - await self._save_event.wait() - return self._segments - - async def join(self): - """Verify save worker was invoked and block on shutdown.""" - async with async_timeout.timeout(TEST_TIMEOUT): - await self._save_event.wait() - self._save_thread.join(timeout=TEST_TIMEOUT) - assert not self._save_thread.is_alive() - - def reset(self): - """Reset callback state for reuse in tests.""" - self._save_thread = None - self._save_event = asyncio.Event() - - -@pytest.fixture() -def record_worker_sync(hass): - """Patch recorder_save_worker for clean thread shutdown for test.""" - sync = SaveRecordWorkerSync(hass) - with patch( - "homeassistant.components.stream.recorder.recorder_save_worker", - side_effect=sync.recorder_save_worker, - autospec=True, - ): - yield sync - - class HLSSync: """Test fixture that intercepts stream worker calls to StreamOutput.""" diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 8e01c55de84..715e69fb889 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -144,7 +144,7 @@ async def test_hls_stream( # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() hls_client = await hls_stream(stream) @@ -171,7 +171,7 @@ async def test_hls_stream( stream_worker_sync.resume() # Stop stream, if it hasn't quit already - stream.stop() + await stream.stop() # Ensure playlist not accessible after stream ends fail_response = await hls_client.get() @@ -205,7 +205,7 @@ async def test_stream_timeout( # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() url = stream.endpoint_url(HLS_PROVIDER) http_client = await hass_client() @@ -218,6 +218,7 @@ async def test_stream_timeout( # Wait a minute future = dt_util.utcnow() + timedelta(minutes=1) async_fire_time_changed(hass, future) + await hass.async_block_till_done() # Fetch again to reset timer playlist_response = await http_client.get(parsed_url.path) @@ -249,10 +250,10 @@ async def test_stream_timeout_after_stop( # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() stream_worker_sync.resume() - stream.stop() + await stream.stop() # Wait 5 minutes and fire callback. Stream should already have been # stopped so this is a no-op. @@ -297,14 +298,14 @@ async def test_stream_retries(hass, setup_component, should_retry): mock_time.time.side_effect = time_side_effect # Request stream. Enable retries which are disabled by default in tests. should_retry.return_value = True - stream.start() + await stream.start() stream._thread.join() stream._thread = None assert av_open.call_count == 2 await hass.async_block_till_done() # Stop stream, if it hasn't quit already - stream.stop() + await stream.stop() # Stream marked initially available, then marked as failed, then marked available # before the final failure that exits the stream. @@ -351,7 +352,7 @@ async def test_hls_playlist_view(hass, setup_component, hls_stream, stream_worke ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_hls_max_segments(hass, setup_component, hls_stream, stream_worker_sync): @@ -400,7 +401,7 @@ async def test_hls_max_segments(hass, setup_component, hls_stream, stream_worker assert segment_response.status == HTTPStatus.OK stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_hls_playlist_view_discontinuity( @@ -438,7 +439,7 @@ async def test_hls_playlist_view_discontinuity( ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_hls_max_segments_discontinuity( @@ -481,7 +482,7 @@ async def test_hls_max_segments_discontinuity( ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_remove_incomplete_segment_on_exit( @@ -490,7 +491,7 @@ async def test_remove_incomplete_segment_on_exit( """Test that the incomplete segment gets removed when the worker thread quits.""" stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() - stream.start() + await stream.start() hls = stream.add_provider(HLS_PROVIDER) segment = Segment(sequence=0, stream_id=0, duration=SEGMENT_DURATION) @@ -505,10 +506,12 @@ async def test_remove_incomplete_segment_on_exit( assert len(segments) == 3 assert not segments[-1].complete stream_worker_sync.resume() - stream._thread_quit.set() - stream._thread.join() - stream._thread = None - await hass.async_block_till_done() - assert segments[-1].complete - assert len(segments) == 2 - stream.stop() + with patch("homeassistant.components.stream.Stream.remove_provider"): + # Patch remove_provider so the deque is not cleared + stream._thread_quit.set() + stream._thread.join() + stream._thread = None + await hass.async_block_till_done() + assert segments[-1].complete + assert len(segments) == 2 + await stream.stop() diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 9a0d94136b9..447b9ff58e9 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -91,12 +91,12 @@ def make_segment_with_parts( ): """Create a playlist response for a segment including part segments.""" response = [] + if discontinuity: + response.append("#EXT-X-DISCONTINUITY") for i in range(num_parts): response.append( f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' ) - if discontinuity: - response.append("#EXT-X-DISCONTINUITY") response.extend( [ "#EXT-X-PROGRAM-DATE-TIME:" @@ -144,7 +144,7 @@ async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync): # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() hls_client = await hls_stream(stream) @@ -243,7 +243,7 @@ async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync): stream_worker_sync.resume() # Stop stream, if it hasn't quit already - stream.stop() + await stream.stop() # Ensure playlist not accessible after stream ends fail_response = await hls_client.get() @@ -316,7 +316,7 @@ async def test_ll_hls_playlist_view(hass, hls_stream, stream_worker_sync): ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_ll_hls_msn(hass, hls_stream, stream_worker_sync, hls_sync): diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 50aa4df3f1c..d7595b47679 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -1,4 +1,5 @@ -"""The tests for hls streams.""" +"""The tests for recording streams.""" +import asyncio from datetime import timedelta from io import BytesIO import os @@ -7,11 +8,14 @@ from unittest.mock import patch import av import pytest -from homeassistant.components.stream import create_stream -from homeassistant.components.stream.const import HLS_PROVIDER, RECORDER_PROVIDER +from homeassistant.components.stream import Stream, create_stream +from homeassistant.components.stream.const import ( + HLS_PROVIDER, + OUTPUT_IDLE_TIMEOUT, + RECORDER_PROVIDER, +) from homeassistant.components.stream.core import Part from homeassistant.components.stream.fmp4utils import find_box -from homeassistant.components.stream.recorder import recorder_save_worker from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -20,91 +24,72 @@ from .common import DefaultSegment as Segment, generate_h264_video, remux_with_a from tests.common import async_fire_time_changed -MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever - -async def test_record_stream(hass, hass_client, record_worker_sync, h264_video): - """ - Test record stream. - - Tests full integration with the stream component, and captures the - stream worker and save worker to allow for clean shutdown of background - threads. The actual save logic is tested in test_recorder_save below. - """ +@pytest.fixture(autouse=True) +async def stream_component(hass): + """Set up the component before each test.""" await async_setup_component(hass, "stream", {"stream": {}}) - # Setup demo track - stream = create_stream(hass, h264_video, {}) + +@pytest.fixture +def filename(tmpdir): + """Use this filename for the tests.""" + return f"{tmpdir}/test.mp4" + + +async def test_record_stream(hass, filename, h264_video): + """Test record stream.""" + + worker_finished = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch remove_provider.""" + + async def remove_provider(self, provider): + """Add a finished event to Stream.remove_provider.""" + await Stream.remove_provider(self, provider) + worker_finished.set() + + with patch("homeassistant.components.stream.Stream", wraps=MockStream): + stream = create_stream(hass, h264_video, {}) + with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + make_recording = hass.async_create_task(stream.async_record(filename)) - # After stream decoding finishes, the record worker thread starts - segments = await record_worker_sync.get_segments() - assert len(segments) >= 1 + # In general usage the recorder will only include what has already been + # processed by the worker. To guarantee we have some output for the test, + # wait until the worker has finished before firing + await worker_finished.wait() - # Verify that the save worker was invoked, then block until its - # thread completes and is shutdown completely to avoid thread leaks. - await record_worker_sync.join() + # Fire the IdleTimer + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) - stream.stop() + await make_recording + + # Assert + assert os.path.exists(filename) -async def test_record_lookback( - hass, hass_client, stream_worker_sync, record_worker_sync, h264_video -): +async def test_record_lookback(hass, h264_video): """Exercise record with loopback.""" - await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, h264_video, {}) # Start an HLS feed to enable lookback stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path", lookback=4) # This test does not need recorder cleanup since it is not fully exercised - stream.stop() + await stream.stop() -async def test_recorder_timeout(hass, hass_client, stream_worker_sync, h264_video): - """ - Test recorder timeout. - - Mocks out the cleanup to assert that it is invoked after a timeout. - This test does not start the recorder save thread. - """ - await async_setup_component(hass, "stream", {"stream": {}}) - - stream_worker_sync.pause() - - with patch("homeassistant.components.stream.IdleTimer.fire") as mock_timeout: - # Setup demo track - stream = create_stream(hass, h264_video, {}) - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") - recorder = stream.add_provider(RECORDER_PROVIDER) - - await recorder.recv() - - # Wait a minute - future = dt_util.utcnow() + timedelta(minutes=1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert mock_timeout.called - - stream_worker_sync.resume() - stream.stop() - await hass.async_block_till_done() - await hass.async_block_till_done() - - -async def test_record_path_not_allowed(hass, hass_client, h264_video): +async def test_record_path_not_allowed(hass, h264_video): """Test where the output path is not allowed by home assistant configuration.""" - await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, h264_video, {}) with patch.object( @@ -127,25 +112,8 @@ def add_parts_to_segment(segment, source): ] -async def test_recorder_save(tmpdir, h264_video): - """Test recorder save.""" - # Setup - filename = f"{tmpdir}/test.mp4" - - # Run - segment = Segment(sequence=1) - add_parts_to_segment(segment, h264_video) - segment.duration = 4 - recorder_save_worker(filename, [segment]) - - # Assert - assert os.path.exists(filename) - - -async def test_recorder_discontinuity(tmpdir, h264_video): +async def test_recorder_discontinuity(hass, filename, h264_video): """Test recorder save across a discontinuity.""" - # Setup - filename = f"{tmpdir}/test.mp4" # Run segment_1 = Segment(sequence=1, stream_id=0) @@ -154,18 +122,50 @@ async def test_recorder_discontinuity(tmpdir, h264_video): segment_2 = Segment(sequence=2, stream_id=1) add_parts_to_segment(segment_2, h264_video) segment_2.duration = 4 - recorder_save_worker(filename, [segment_1, segment_2]) + + provider_ready = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch add_provider.""" + + async def start(self): + """Make Stream.start a noop that gives up async context.""" + await asyncio.sleep(0) + + def add_provider(self, fmt, timeout=OUTPUT_IDLE_TIMEOUT): + """Add a finished event to Stream.add_provider.""" + provider = Stream.add_provider(self, fmt, timeout) + provider_ready.set() + return provider + + with patch.object(hass.config, "is_allowed_path", return_value=True), patch( + "homeassistant.components.stream.Stream", wraps=MockStream + ), patch("homeassistant.components.stream.recorder.RecorderOutput.recv"): + stream = create_stream(hass, "blank", {}) + make_recording = hass.async_create_task(stream.async_record(filename)) + await provider_ready.wait() + + recorder_output = stream.outputs()[RECORDER_PROVIDER] + recorder_output.idle_timer.start() + recorder_output._segments.extend([segment_1, segment_2]) + + # Fire the IdleTimer + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + + await make_recording # Assert assert os.path.exists(filename) -async def test_recorder_no_segments(tmpdir): +async def test_recorder_no_segments(hass, filename): """Test recorder behavior with a stream failure which causes no segments.""" - # Setup - filename = f"{tmpdir}/test.mp4" + + stream = create_stream(hass, BytesIO(), {}) # Run - recorder_save_worker("unused-file", []) + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record(filename) # Assert assert not os.path.exists(filename) @@ -188,9 +188,7 @@ def h264_mov_video(): ) async def test_record_stream_audio( hass, - hass_client, - stream_worker_sync, - record_worker_sync, + filename, audio_codec, expected_audio_streams, h264_mov_video, @@ -201,45 +199,54 @@ async def test_record_stream_audio( Record stream output should have an audio channel when input has a valid codec and audio packets and no audio channel otherwise. """ - await async_setup_component(hass, "stream", {"stream": {}}) # Remux source video with new audio source = remux_with_audio(h264_mov_video, "mov", audio_codec) # mov can store PCM - record_worker_sync.reset() - stream_worker_sync.pause() + worker_finished = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch remove_provider.""" + + async def remove_provider(self, provider): + """Add a finished event to Stream.remove_provider.""" + await Stream.remove_provider(self, provider) + worker_finished.set() + + with patch("homeassistant.components.stream.Stream", wraps=MockStream): + stream = create_stream(hass, source, {}) - stream = create_stream(hass, source, {}) with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") - recorder = stream.add_provider(RECORDER_PROVIDER) + make_recording = hass.async_create_task(stream.async_record(filename)) - while True: - await recorder.recv() - if not (segment := recorder.last_segment): - break - last_segment = segment - stream_worker_sync.resume() + # In general usage the recorder will only include what has already been + # processed by the worker. To guarantee we have some output for the test, + # wait until the worker has finished before firing + await worker_finished.wait() + + # Fire the IdleTimer + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + + await make_recording + + # Assert + assert os.path.exists(filename) result = av.open( - BytesIO(last_segment.init + last_segment.get_data()), + filename, "r", format="mp4", ) assert len(result.streams.audio) == expected_audio_streams result.close() - stream.stop() + await stream.stop() await hass.async_block_till_done() - # Verify that the save worker was invoked, then block until its - # thread completes and is shutdown completely to avoid thread leaks. - await record_worker_sync.join() - async def test_recorder_log(hass, caplog): """Test starting a stream to record logs the url without username and password.""" - await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, "https://abcd:efgh@foo.bar", {}) with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path") diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 2a44dd64455..8717e23a476 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -13,6 +13,7 @@ pushed to the output streams. The packet sequence can be used to exercise failure modes or corner cases like how out of order packets are handled. """ +import asyncio import fractions import io import logging @@ -33,6 +34,7 @@ from homeassistant.components.stream.const import ( HLS_PROVIDER, MAX_MISSING_DTS, PACKETS_TO_WAIT_FOR_AUDIO, + RECORDER_PROVIDER, SEGMENT_DURATION_ADJUSTER, TARGET_SEGMENT_DURATION_NON_LL_HLS, ) @@ -268,17 +270,24 @@ class MockPyAv: return self.container -def run_worker(hass, stream, stream_source): +def run_worker(hass, stream, stream_source, stream_settings=None): """Run the stream worker under test.""" stream_state = StreamState(hass, stream.outputs, stream._diagnostics) stream_worker( - stream_source, {}, stream_state, KeyFrameConverter(hass), threading.Event() + stream_source, + {}, + stream_settings or hass.data[DOMAIN][ATTR_SETTINGS], + stream_state, + KeyFrameConverter(hass), + threading.Event(), ) -async def async_decode_stream(hass, packets, py_av=None): +async def async_decode_stream(hass, packets, py_av=None, stream_settings=None): """Start a stream worker that decodes incoming stream packets into output segments.""" - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream( + hass, STREAM_SOURCE, {}, stream_settings or hass.data[DOMAIN][ATTR_SETTINGS] + ) stream.add_provider(HLS_PROVIDER) if not py_av: @@ -290,7 +299,7 @@ async def async_decode_stream(hass, packets, py_av=None): side_effect=py_av.capture_buffer.capture_output_segment, ): try: - run_worker(hass, stream, STREAM_SOURCE) + run_worker(hass, stream, STREAM_SOURCE, stream_settings) except StreamEndedError: # Tests only use a limited number of packets, then the worker exits as expected. In # production, stream ending would be unexpected. @@ -304,7 +313,7 @@ async def async_decode_stream(hass, packets, py_av=None): async def test_stream_open_fails(hass): """Test failure on stream open.""" - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS]) stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open, pytest.raises(StreamWorkerError): av_open.side_effect = av.error.InvalidDataError(-2, "error") @@ -543,25 +552,6 @@ async def test_audio_packets_not_found(hass): assert len(decoded_stream.audio_packets) == 0 -async def test_adts_aac_audio(hass): - """Set up an ADTS AAC audio stream and disable audio.""" - py_av = MockPyAv(audio=True) - - num_packets = PACKETS_TO_WAIT_FOR_AUDIO + 1 - packets = list(PacketSequence(num_packets)) - packets[1].stream = AUDIO_STREAM - packets[1].dts = int(packets[0].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - packets[1].pts = int(packets[0].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - # The following is packet data is a sign of ADTS AAC - packets[1][0] = 255 - packets[1][1] = 241 - - decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) - assert len(decoded_stream.audio_packets) == 0 - # All decoded video packets are still preserved - assert len(decoded_stream.video_packets) == num_packets - 1 - - async def test_audio_is_first_packet(hass): """Set up an audio stream and audio packet is the first packet in the stream.""" py_av = MockPyAv(audio=True) @@ -637,7 +627,7 @@ async def test_stream_stopped_while_decoding(hass): worker_open = threading.Event() worker_wake = threading.Event() - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS]) stream.add_provider(HLS_PROVIDER) py_av = MockPyAv() @@ -651,12 +641,12 @@ async def test_stream_stopped_while_decoding(hass): return py_av.open(stream_source, args, kwargs) with patch("av.open", new=blocking_open): - stream.start() + await stream.start() assert worker_open.wait(TIMEOUT) # Note: There is a race here where the worker could start as soon # as the wake event is sent, completing all decode work. worker_wake.set() - stream.stop() + await stream.stop() # Stream is still considered available when the worker was still active and asked to stop assert stream.available @@ -667,7 +657,7 @@ async def test_update_stream_source(hass): worker_open = threading.Event() worker_wake = threading.Event() - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS]) stream.add_provider(HLS_PROVIDER) # Note that retries are disabled by default in tests, however the stream is "restarted" when # the stream source is updated. @@ -688,7 +678,7 @@ async def test_update_stream_source(hass): return py_av.open(stream_source, args, kwargs) with patch("av.open", new=blocking_open): - stream.start() + await stream.start() assert worker_open.wait(TIMEOUT) assert last_stream_source == STREAM_SOURCE assert stream.available @@ -704,12 +694,14 @@ async def test_update_stream_source(hass): assert stream.available # Cleanup - stream.stop() + await stream.stop() async def test_worker_log(hass, caplog): """Test that the worker logs the url without username and password.""" - stream = Stream(hass, "https://abcd:efgh@foo.bar", {}) + stream = Stream( + hass, "https://abcd:efgh@foo.bar", {}, hass.data[DOMAIN][ATTR_SETTINGS] + ) stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open, pytest.raises(StreamWorkerError) as err: @@ -723,7 +715,23 @@ async def test_worker_log(hass, caplog): assert "https://abcd:efgh@foo.bar" not in caplog.text -async def test_durations(hass, record_worker_sync): +@pytest.fixture +def worker_finished_stream(): + """Fixture that helps call a stream and wait for the worker to finish.""" + worker_finished = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch remove_provider.""" + + async def remove_provider(self, provider): + """Add a finished event to Stream.remove_provider.""" + await Stream.remove_provider(self, provider) + worker_finished.set() + + return worker_finished, MockStream + + +async def test_durations(hass, worker_finished_stream): """Test that the duration metadata matches the media.""" # Use a target part duration which has a slight mismatch @@ -742,13 +750,17 @@ async def test_durations(hass, record_worker_sync): ) source = generate_h264_video(duration=SEGMENT_DURATION + 1) - stream = create_stream(hass, source, {}, stream_label="camera") + worker_finished, mock_stream = worker_finished_stream - # use record_worker_sync to grab output segments - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + with patch("homeassistant.components.stream.Stream", wraps=mock_stream): + stream = create_stream(hass, source, {}, stream_label="camera") + + recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) + await stream.start() + await worker_finished.wait() + + complete_segments = list(recorder_output.get_segments())[:-1] - complete_segments = list(await record_worker_sync.get_segments())[:-1] assert len(complete_segments) >= 1 # check that the Part duration metadata matches the durations in the media @@ -794,12 +806,10 @@ async def test_durations(hass, record_worker_sync): abs_tol=1e-6, ) - await record_worker_sync.join() - - stream.stop() + await stream.stop() -async def test_has_keyframe(hass, record_worker_sync, h264_video): +async def test_has_keyframe(hass, h264_video, worker_finished_stream): """Test that the has_keyframe metadata matches the media.""" await async_setup_component( hass, @@ -815,13 +825,17 @@ async def test_has_keyframe(hass, record_worker_sync, h264_video): }, ) - stream = create_stream(hass, h264_video, {}, stream_label="camera") + worker_finished, mock_stream = worker_finished_stream - # use record_worker_sync to grab output segments - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + with patch("homeassistant.components.stream.Stream", wraps=mock_stream): + stream = create_stream(hass, h264_video, {}, stream_label="camera") + + recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) + await stream.start() + await worker_finished.wait() + + complete_segments = list(recorder_output.get_segments())[:-1] - complete_segments = list(await record_worker_sync.get_segments())[:-1] assert len(complete_segments) >= 1 # check that the Part has_keyframe metadata matches the keyframes in the media @@ -834,12 +848,10 @@ async def test_has_keyframe(hass, record_worker_sync, h264_video): av_part.close() assert part.has_keyframe == media_has_keyframe - await record_worker_sync.join() - - stream.stop() + await stream.stop() -async def test_h265_video_is_hvc1(hass, record_worker_sync): +async def test_h265_video_is_hvc1(hass, worker_finished_stream): """Test that a h265 video gets muxed as hvc1.""" await async_setup_component( hass, @@ -854,13 +866,16 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): ) source = generate_h265_video() - stream = create_stream(hass, source, {}, stream_label="camera") - # use record_worker_sync to grab output segments - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + worker_finished, mock_stream = worker_finished_stream + with patch("homeassistant.components.stream.Stream", wraps=mock_stream): + stream = create_stream(hass, source, {}, stream_label="camera") - complete_segments = list(await record_worker_sync.get_segments())[:-1] + recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) + await stream.start() + await worker_finished.wait() + + complete_segments = list(recorder_output.get_segments())[:-1] assert len(complete_segments) >= 1 segment = complete_segments[0] @@ -869,9 +884,7 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): assert av_part.streams.video[0].codec_tag == "hvc1" av_part.close() - await record_worker_sync.join() - - stream.stop() + await stream.stop() assert stream.get_diagnostics() == { "container_format": "mov,mp4,m4a,3gp,3g2,mj2", @@ -882,7 +895,7 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): } -async def test_get_image(hass, record_worker_sync): +async def test_get_image(hass): """Test that the has_keyframe metadata matches the media.""" await async_setup_component(hass, "stream", {"stream": {}}) @@ -895,14 +908,32 @@ async def test_get_image(hass, record_worker_sync): mock_turbo_jpeg_singleton.instance.return_value = mock_turbo_jpeg() stream = create_stream(hass, source, {}) - # use record_worker_sync to grab output segments with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") - + make_recording = hass.async_create_task(stream.async_record("/example/path")) + await make_recording assert stream._keyframe_converter._image is None - await record_worker_sync.join() - assert await stream.async_get_image() == EMPTY_8_6_JPEG - stream.stop() + await stream.stop() + + +async def test_worker_disable_ll_hls(hass): + """Test that the worker disables ll-hls for hls inputs.""" + stream_settings = StreamSettings( + ll_hls=True, + min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS, + hls_advance_part_limit=3, + hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS, + ) + py_av = MockPyAv() + py_av.container.format.name = "hls" + await async_decode_stream( + hass, + PacketSequence(TEST_SEQUENCE_LENGTH), + py_av=py_av, + stream_settings=stream_settings, + ) + assert stream_settings.ll_hls is False diff --git a/tests/components/sun/test_recorder.py b/tests/components/sun/test_recorder.py index 0b0d0ad48cf..547bf44ec5f 100644 --- a/tests/components/sun/test_recorder.py +++ b/tests/components/sun/test_recorder.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.sun import ( DOMAIN, diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index dc4ca96aa97..d80f7e24bb1 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -65,8 +65,8 @@ async def test_config_flow( @pytest.mark.parametrize( "hidden_by_before,hidden_by_after", ( - (er.RegistryEntryHider.USER.value, er.RegistryEntryHider.USER.value), - (None, er.RegistryEntryHider.INTEGRATION.value), + (er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), + (None, er.RegistryEntryHider.INTEGRATION), ), ) @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index e2b875b813b..9c3eec1884c 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -365,8 +365,8 @@ async def test_setup_and_remove_config_entry( @pytest.mark.parametrize( "hidden_by_before,hidden_by_after", ( - (er.RegistryEntryHider.USER.value, er.RegistryEntryHider.USER.value), - (er.RegistryEntryHider.INTEGRATION.value, None), + (er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), + (er.RegistryEntryHider.INTEGRATION, None), ), ) @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 2fdea69de16..550aeb08082 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -45,6 +45,19 @@ class MocGetSwitchbotDevices: "model": "m", "rawAdvData": "000d6d00", }, + "c0ceb0d426be": { + "mac_address": "c0:ce:b0:d4:26:be", + "isEncrypted": False, + "data": { + "temp": {"c": 21.6, "f": 70.88}, + "fahrenheit": False, + "humidity": 73, + "battery": 100, + "rssi": -58, + }, + "model": "T", + "modelName": "WoSensorTH", + }, } self._curtain_all_services_data = { "mac_address": "e7:89:43:90:90:90", @@ -72,11 +85,11 @@ class MocGetSwitchbotDevices: "modelName": "WoOther", } - def discover(self, retry=0, scan_timeout=0): + async def discover(self, retry=0, scan_timeout=0): """Mock discover.""" return self._all_services_data - def get_device_data(self, mac=None): + async def get_device_data(self, mac=None): """Return data for specific device.""" if mac == "e7:89:43:99:99:99": return self._all_services_data diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 4d6708a2e79..db373f41656 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -24,9 +24,9 @@ from tests.common import MockConfigEntry @pytest.mark.no_bypass_setup async def test_services_registered(hass: HomeAssistant): """Test if all services are registered.""" - with patch( - "homeassistant.components.synology_dsm.SynoApi.async_setup", return_value=True - ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + with patch("homeassistant.components.synology_dsm.common.SynologyDSM"), patch( + "homeassistant.components.synology_dsm.PLATFORMS", return_value=[] + ): entry = MockConfigEntry( domain=DOMAIN, data={ diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 94d116bbd36..515146bc16c 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -1,13 +1,28 @@ """Test the System Bridge config flow.""" +import asyncio from unittest.mock import patch -from aiohttp.client_exceptions import ClientConnectionError -from systembridge.exceptions import BridgeAuthenticationException +from systembridgeconnector.const import ( + EVENT_DATA, + EVENT_MESSAGE, + EVENT_MODULE, + EVENT_SUBTYPE, + EVENT_TYPE, + SUBTYPE_BAD_API_KEY, + TYPE_DATA_UPDATE, + TYPE_ERROR, +) +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, +) from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.system_bridge.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -29,7 +44,7 @@ FIXTURE_ZEROCONF_INPUT = { } FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", + host="test-bridge", addresses=["1.1.1.1"], port=9170, hostname="test-bridge.local.", @@ -58,37 +73,40 @@ FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( }, ) - -FIXTURE_INFORMATION = { - "address": "http://test-bridge:9170", - "apiPort": 9170, - "fqdn": "test-bridge", - "host": "test-bridge", - "ip": "1.1.1.1", - "mac": FIXTURE_MAC_ADDRESS, - "updates": { - "available": False, - "newer": False, - "url": "https://github.com/timmo001/system-bridge/releases/tag/v2.3.2", - "version": {"current": "2.3.2", "new": "2.3.2"}, +FIXTURE_DATA_SYSTEM = { + EVENT_TYPE: TYPE_DATA_UPDATE, + EVENT_MESSAGE: "Data changed", + EVENT_MODULE: "system", + EVENT_DATA: { + "uuid": FIXTURE_UUID, }, - "uuid": FIXTURE_UUID, - "version": "2.3.2", - "websocketAddress": "ws://test-bridge:9172", - "websocketPort": 9172, +} + +FIXTURE_DATA_SYSTEM_BAD = { + EVENT_TYPE: TYPE_DATA_UPDATE, + EVENT_MESSAGE: "Data changed", + EVENT_MODULE: "system", + EVENT_DATA: {}, +} + +FIXTURE_DATA_AUTH_ERROR = { + EVENT_TYPE: TYPE_ERROR, + EVENT_SUBTYPE: SUBTYPE_BAD_API_KEY, + EVENT_MESSAGE: "Invalid api-key", } -FIXTURE_BASE_URL = ( - f"http://{FIXTURE_USER_INPUT[CONF_HOST]}:{FIXTURE_USER_INPUT[CONF_PORT]}" -) +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) -FIXTURE_ZEROCONF_BASE_URL = f"http://{FIXTURE_ZEROCONF.host}:{FIXTURE_ZEROCONF.port}" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" -async def test_user_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test full user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -97,20 +115,19 @@ async def test_user_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", - headers={"Content-Type": "application/json"}, - json=FIXTURE_INFORMATION, - ) - with patch( + "homeassistant.components.system_bridge.config_flow.WebSocketClient.connect" + ), patch("systembridgeconnector.websocket_client.WebSocketClient.get_data"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM, + ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "test-bridge" @@ -118,34 +135,7 @@ async def test_user_flow( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] is None - - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +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} @@ -154,11 +144,13 @@ async def test_form_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT - ) + with patch( + "systembridgeconnector.websocket_client.WebSocketClient.connect", + side_effect=ConnectionErrorException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -166,10 +158,8 @@ async def test_form_cannot_connect( assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unknown_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: - """Test we handle unknown error.""" +async def test_form_connection_closed_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle connection closed cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -177,23 +167,123 @@ async def test_form_unknown_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - with patch( - "homeassistant.components.system_bridge.config_flow.Bridge.async_get_information", - side_effect=Exception("Boom"), + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=ConnectionClosedException, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_timeout_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle timeout cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +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} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=AuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_uuid_error(hass: HomeAssistant) -> None: + """Test we handle error from bad uuid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM_BAD, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "unknown"} -async def test_reauth_authorization_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_reauth_authorization_error(hass: HomeAssistant) -> None: """Test we show user form on authorization error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT @@ -202,13 +292,15 @@ async def test_reauth_authorization_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_AUTH_INPUT - ) + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=AuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -216,9 +308,7 @@ async def test_reauth_authorization_error( assert result2["errors"] == {"base": "invalid_auth"} -async def test_reauth_connection_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_reauth_connection_error(hass: HomeAssistant) -> None: """Test we show user form on connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT @@ -227,11 +317,13 @@ async def test_reauth_connection_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_AUTH_INPUT - ) + with patch( + "systembridgeconnector.websocket_client.WebSocketClient.connect", + side_effect=ConnectionErrorException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -239,9 +331,32 @@ async def test_reauth_connection_error( assert result2["errors"] == {"base": "cannot_connect"} -async def test_reauth_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_reauth_connection_closed_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=ConnectionClosedException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "authenticate" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: """Test reauth flow.""" mock_config = MockConfigEntry( domain=DOMAIN, unique_id=FIXTURE_UUID, data=FIXTURE_USER_INPUT @@ -255,20 +370,19 @@ async def test_reauth_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", - headers={"Content-Type": "application/json"}, - json=FIXTURE_INFORMATION, - ) - - with patch( + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM, + ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result2["reason"] == "reauth_successful" @@ -276,9 +390,7 @@ async def test_reauth_flow( assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_zeroconf_flow(hass: HomeAssistant) -> None: """Test zeroconf flow.""" result = await hass.config_entries.flow.async_init( @@ -290,30 +402,27 @@ async def test_zeroconf_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get( - f"{FIXTURE_ZEROCONF_BASE_URL}/information", - headers={"Content-Type": "application/json"}, - json=FIXTURE_INFORMATION, - ) - - with patch( + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM, + ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "test-bridge" + assert result2["title"] == "1.1.1.1" assert result2["data"] == FIXTURE_ZEROCONF_INPUT assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_cannot_connect( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: """Test zeroconf cannot connect flow.""" result = await hass.config_entries.flow.async_init( @@ -325,13 +434,13 @@ async def test_zeroconf_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get( - f"{FIXTURE_ZEROCONF_BASE_URL}/information", exc=ClientConnectionError - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_AUTH_INPUT - ) + with patch( + "systembridgeconnector.websocket_client.WebSocketClient.connect", + side_effect=ConnectionErrorException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -339,9 +448,7 @@ async def test_zeroconf_cannot_connect( assert result2["errors"] == {"base": "cannot_connect"} -async def test_zeroconf_bad_zeroconf_info( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_zeroconf_bad_zeroconf_info(hass: HomeAssistant) -> None: """Test zeroconf cannot connect flow.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 8b9284a4b32..121c29d2eed 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -53,7 +53,7 @@ async def get_error_log(hass_ws_client): def _generate_and_log_exception(exception, log): try: raise Exception(exception) - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.exception(log) diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index f48a09fd64b..cae78a447f8 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant.components.tankerkoenig.const import ( CONF_STATIONS, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -222,6 +222,59 @@ async def test_import(hass: HomeAssistant): assert mock_setup_entry.called +async def test_reauth(hass: HomeAssistant): + """Test starting a flow by user to re-auth.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + data={**MOCK_USER_DATA, **MOCK_STATIONS_DATA}, + unique_id=f"{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True + ) as mock_setup_entry, patch( + "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", + ) as mock_nearby_stations: + # re-auth initialized + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + # re-auth unsuccessful + mock_nearby_stations.return_value = {"ok": False} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx", + }, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {CONF_API_KEY: "invalid_auth"} + + # re-auth successful + mock_nearby_stations.return_value = MOCK_NEARVY_STATIONS_OK + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx", + }, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + mock_setup_entry.assert_called() + + entry = hass.config_entries.async_get_entry(mock_config.entry_id) + assert entry.data[CONF_API_KEY] == "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx" + + async def test_options_flow(hass: HomeAssistant): """Test options flow.""" diff --git a/tests/components/tautulli/test_config_flow.py b/tests/components/tautulli/test_config_flow.py index 95ccfbaa9b7..d37e4401275 100644 --- a/tests/components/tautulli/test_config_flow.py +++ b/tests/components/tautulli/test_config_flow.py @@ -2,11 +2,10 @@ from unittest.mock import AsyncMock, patch from pytautulli import exceptions -from pytest import LogCaptureFixture from homeassistant import data_entry_flow -from homeassistant.components.tautulli.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.components.tautulli.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE from homeassistant.core import HomeAssistant @@ -127,37 +126,6 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: assert result2["data"] == CONF_DATA -async def test_flow_import(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: - """Test import step.""" - with patch_config_flow_tautulli(AsyncMock()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_IMPORT_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"] == CONF_DATA - assert "Tautulli platform in YAML" in caplog.text - - -async def test_flow_import_single_instance_allowed(hass: HomeAssistant) -> None: - """Test import step single instance allowed.""" - entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) - entry.add_to_hass(hass) - - with patch_config_flow_tautulli(AsyncMock()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_IMPORT_DATA, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" - - async def test_flow_reauth( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index ddf13c2015b..44fa96cfc6a 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -624,20 +624,32 @@ async def test_sun_renders_once_per_sensor(hass, start_ha): } -@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize("count,domain", [(1, "template")]) @pytest.mark.parametrize( "config", [ { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ this.attributes.test }}: {{ this.entity_id }}", - "attribute_templates": { - "test": "It {{ states.sensor.test_state.state }}" - }, - } + "template": { + "sensor": { + "name": "test_template_sensor", + "state": "{{ this.attributes.test }}: {{ this.entity_id }}", + "attributes": {"test": "It {{ states.sensor.test_state.state }}"}, + }, + }, + }, + { + "template": { + "trigger": { + "platform": "state", + "entity_id": [ + "sensor.test_state", + "sensor.test_template_sensor", + ], + }, + "sensor": { + "name": "test_template_sensor", + "state": "{{ this.attributes.test }}: {{ this.entity_id }}", + "attributes": {"test": "It {{ states.sensor.test_state.state }}"}, }, }, }, diff --git a/tests/components/tomorrowio/conftest.py b/tests/components/tomorrowio/conftest.py index 65c69209f0e..9c1fa5baa06 100644 --- a/tests/components/tomorrowio/conftest.py +++ b/tests/components/tomorrowio/conftest.py @@ -1,6 +1,6 @@ """Configure py.test.""" import json -from unittest.mock import patch +from unittest.mock import PropertyMock, patch import pytest @@ -23,8 +23,16 @@ def tomorrowio_config_entry_update_fixture(): with patch( "homeassistant.components.tomorrowio.TomorrowioV4.realtime_and_all_forecasts", return_value=json.loads(load_fixture("v4.json", "tomorrowio")), - ): - yield + ) as mock_update, patch( + "homeassistant.components.tomorrowio.TomorrowioV4.max_requests_per_day", + new_callable=PropertyMock, + ) as mock_max_requests_per_day, patch( + "homeassistant.components.tomorrowio.TomorrowioV4.num_api_requests", + new_callable=PropertyMock, + ) as mock_num_api_requests: + mock_max_requests_per_day.return_value = 100 + mock_num_api_requests.return_value = 2 + yield mock_update @pytest.fixture(name="climacell_config_entry_update") diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index c9914bf95be..2c0a882ff5a 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -1,4 +1,7 @@ """Tests for Tomorrow.io init.""" +from datetime import timedelta +from unittest.mock import patch + from homeassistant.components.climacell.const import CONF_TIMESTEP, DOMAIN as CC_DOMAIN from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, @@ -17,10 +20,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from .const import MIN_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.climacell.const import API_V3_ENTRY_DATA NEW_NAME = "New Name" @@ -47,6 +51,83 @@ async def test_load_and_unload(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 +async def test_update_intervals( + hass: HomeAssistant, tomorrowio_config_entry_update +) -> None: + """Test coordinator update intervals.""" + now = dt_util.utcnow() + data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) + data[CONF_NAME] = "test" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: 1}, + unique_id=_get_unique_id(hass, data), + version=1, + ) + config_entry.add_to_hass(hass) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=now): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + + tomorrowio_config_entry_update.reset_mock() + + # Before the update interval, no updates yet + future = now + timedelta(minutes=30) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 + + tomorrowio_config_entry_update.reset_mock() + + # On the update interval, we get a new update + future = now + timedelta(minutes=32) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, now + timedelta(minutes=32)) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + + tomorrowio_config_entry_update.reset_mock() + + # Adding a second config entry should cause the update interval to double + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: 1}, + unique_id=f"{_get_unique_id(hass, data)}_1", + version=1, + ) + config_entry_2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry.data[CONF_API_KEY] == config_entry_2.data[CONF_API_KEY] + # We should get an immediate call once the new config entry is setup for a + # partial update + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + + tomorrowio_config_entry_update.reset_mock() + + # We should get no new calls on our old interval + future = now + timedelta(minutes=64) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 + + tomorrowio_config_entry_update.reset_mock() + + # We should get two calls on our new interval, one for each entry + future = now + timedelta(minutes=96) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 2 + + tomorrowio_config_entry_update.reset_mock() + + async def test_climacell_migration_logic( hass: HomeAssistant, climacell_config_entry_update ) -> None: diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index ef025204ea6..51b3db00c6e 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM from .const import API_V4_ENTRY_DATA @@ -169,6 +170,37 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: check_sensor_state(hass, PRECIPITATION_TYPE, "rain") +async def test_v4_sensor_imperial(hass: HomeAssistant) -> None: + """Test v4 sensor data.""" + hass.config.units = IMPERIAL_SYSTEM + await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) + check_sensor_state(hass, O3, "91.35") + check_sensor_state(hass, CO, "0.0") + check_sensor_state(hass, NO2, "20.08") + check_sensor_state(hass, SO2, "4.32") + check_sensor_state(hass, PM25, "0.15") + check_sensor_state(hass, PM10, "0.57") + check_sensor_state(hass, MEP_AQI, "23") + check_sensor_state(hass, MEP_HEALTH_CONCERN, "good") + check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") + check_sensor_state(hass, EPA_AQI, "24") + check_sensor_state(hass, EPA_HEALTH_CONCERN, "good") + check_sensor_state(hass, EPA_PRIMARY_POLLUTANT, "pm25") + check_sensor_state(hass, FIRE_INDEX, "10") + check_sensor_state(hass, GRASS_POLLEN, "none") + check_sensor_state(hass, WEED_POLLEN, "none") + check_sensor_state(hass, TREE_POLLEN, "none") + check_sensor_state(hass, FEELS_LIKE, "214.3") + check_sensor_state(hass, DEW_POINT, "163.08") + check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "29.47") + check_sensor_state(hass, GHI, "0.0") + check_sensor_state(hass, CLOUD_BASE, "0.46") + check_sensor_state(hass, CLOUD_COVER, "100") + check_sensor_state(hass, CLOUD_CEILING, "0.46") + check_sensor_state(hass, WIND_GUST, "28.27") + check_sensor_state(hass, PRECIPITATION_TYPE, "rain") + + async def test_entity_description() -> None: """Test improper entity description raises.""" with pytest.raises(ValueError): diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index f9c7e00b7cd..3d2b0669f5b 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -29,11 +29,16 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_VISIBILITY_UNIT, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, ) from homeassistant.config_entries import SOURCE_USER @@ -99,13 +104,18 @@ async def test_v4_weather(hass: HomeAssistant) -> None: ATTR_FORECAST_TEMP: 45.9, ATTR_FORECAST_TEMP_LOW: 26.1, ATTR_FORECAST_WIND_BEARING: 239.6, - ATTR_FORECAST_WIND_SPEED: 9.49, + ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 - assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 3035.0 + assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 30.35 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == "hPa" assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 44.1 + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == "°C" assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 8.15 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == "km" assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 - assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 9.33 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 33.59 # 9.33 m/s ->km/h + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" diff --git a/tests/components/trafikverket_ferry/__init__.py b/tests/components/trafikverket_ferry/__init__.py index 4a1491c5bed..97bedb30281 100644 --- a/tests/components/trafikverket_ferry/__init__.py +++ b/tests/components/trafikverket_ferry/__init__.py @@ -1 +1,18 @@ """Tests for the Trafikverket Ferry integration.""" +from __future__ import annotations + +from homeassistant.components.trafikverket_ferry.const import ( + CONF_FROM, + CONF_TIME, + CONF_TO, +) +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Harbor 1", + CONF_TO: "Harbor 2", + CONF_TIME: "00:00:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Harbor1", +} diff --git a/tests/components/trafikverket_ferry/conftest.py b/tests/components/trafikverket_ferry/conftest.py new file mode 100644 index 00000000000..452c351ee5d --- /dev/null +++ b/tests/components/trafikverket_ferry/conftest.py @@ -0,0 +1,80 @@ +"""Fixtures for Trafikverket Ferry integration tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant.components.trafikverket_ferry.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="load_int") +async def load_integration_from_entry( + hass: HomeAssistant, get_ferries: list[FerryStop] +) -> MockConfigEntry: + """Set up the Trafikverket Ferry integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_ferries") +def fixture_get_ferries() -> list[FerryStop]: + """Construct FerryStop Mock.""" + + depart1 = FerryStop( + "13", + False, + datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC), + [""], + "0", + datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + "Harbor 1", + "Harbor 2", + ) + depart2 = FerryStop( + "14", + False, + datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=15), + [""], + "0", + datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + "Harbor 1", + "Harbor 2", + ) + depart3 = FerryStop( + "15", + False, + datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=30), + [""], + "0", + datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + "Harbor 1", + "Harbor 2", + ) + + return [depart1, depart2, depart3] diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py new file mode 100644 index 00000000000..7714e0c38f6 --- /dev/null +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -0,0 +1,107 @@ +"""The test for the Trafikverket Ferry coordinator.""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant.components.trafikverket_ferry.const import DOMAIN +from homeassistant.components.trafikverket_ferry.coordinator import next_departuredate +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import STATE_UNAVAILABLE, WEEKDAYS +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + monkeypatch: pytest.MonkeyPatch, + get_ferries: list[FerryStop], +) -> None: + """Test the Trafikverket Ferry coordinator.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + state2 = hass.states.get("sensor.harbor1_departure_to") + state3 = hass.states.get("sensor.harbor1_departure_time") + assert state1.state == "Harbor 1" + assert state2.state == "Harbor 2" + assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + mock_data.reset_mock() + + monkeypatch.setattr( + get_ferries[0], + "departure_time", + datetime(dt.now().year + 2, 5, 1, 12, 0, tzinfo=dt.UTC), + ) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + state2 = hass.states.get("sensor.harbor1_departure_to") + state3 = hass.states.get("sensor.harbor1_departure_time") + assert state1.state == "Harbor 1" + assert state2.state == "Harbor 2" + assert state3.state == str(dt.now().year + 2) + "-05-01T12:00:00+00:00" + mock_data.reset_mock() + + mock_data.side_effect = ValueError("info") + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == STATE_UNAVAILABLE + mock_data.reset_mock() + + mock_data.return_value = get_ferries + mock_data.side_effect = None + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == "Harbor 1" + mock_data.reset_mock() + + +async def test_coordinator_next_departuredate(freezer: FrozenDateTimeFactory) -> None: + """Test the Trafikverket Ferry next_departuredate calculation.""" + freezer.move_to("2022-05-15") + today = date.today() + day_list = ["wed", "thu", "fri", "sat"] + test = next_departuredate(day_list) + assert test == today + timedelta(days=3) + day_list = WEEKDAYS + test = next_departuredate(day_list) + assert test == today + timedelta(days=0) + day_list = ["sun"] + test = next_departuredate(day_list) + assert test == today + timedelta(days=0) + freezer.move_to("2022-05-16") + today = date.today() + day_list = ["wed", "thu", "fri", "sat", "sun"] + test = next_departuredate(day_list) + assert test == today + timedelta(days=2) diff --git a/tests/components/trafikverket_ferry/test_init.py b/tests/components/trafikverket_ferry/test_init.py new file mode 100644 index 00000000000..d5063ab704c --- /dev/null +++ b/tests/components/trafikverket_ferry/test_init.py @@ -0,0 +1,61 @@ +"""Test for Trafikverket Ferry component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant import config_entries +from homeassistant.components.trafikverket_ferry.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry + + +async def test_setup_entry(hass: HomeAssistant, get_ferries: list[FerryStop]) -> None: + """Test setup entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ) as mock_tvt_ferry: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert len(mock_tvt_ferry.mock_calls) == 1 + + +async def test_unload_entry(hass: HomeAssistant, get_ferries: list[FerryStop]) -> None: + """Test unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/trafikverket_ferry/test_sensor.py b/tests/components/trafikverket_ferry/test_sensor.py new file mode 100644 index 00000000000..4353eb5c8ba --- /dev/null +++ b/tests/components/trafikverket_ferry/test_sensor.py @@ -0,0 +1,48 @@ +"""The test for the Trafikverket sensor platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from pytest import MonkeyPatch +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from tests.common import async_fire_time_changed + + +async def test_sensor( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_ferries: list[FerryStop], +) -> None: + """Test the Trafikverket Ferry sensor.""" + state1 = hass.states.get("sensor.harbor1_departure_from") + state2 = hass.states.get("sensor.harbor1_departure_to") + state3 = hass.states.get("sensor.harbor1_departure_time") + assert state1.state == "Harbor 1" + assert state2.state == "Harbor 2" + assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + assert state1.attributes["icon"] == "mdi:ferry" + assert state1.attributes["other_information"] == [""] + assert state2.attributes["icon"] == "mdi:ferry" + + monkeypatch.setattr(get_ferries[0], "other_information", ["Nothing exiting"]) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=6), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == "Harbor 1" + assert state1.attributes["other_information"] == ["Nothing exiting"] diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 09539584cbc..37788fc285b 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -66,6 +66,51 @@ async def test_form(hass: HomeAssistant) -> None: ) +async def test_form_entry_already_exist(hass: HomeAssistant) -> None: + """Test flow aborts when entry already exist.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + @pytest.mark.parametrize( "error_message,base_error", [ diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index 91dfa25fd35..7588736e997 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for Transmission config flow.""" -from datetime import timedelta from unittest.mock import patch import pytest @@ -8,15 +7,7 @@ from transmissionrpc.error import TransmissionError from homeassistant import config_entries, data_entry_flow from homeassistant.components import transmission from homeassistant.components.transmission import config_flow -from homeassistant.components.transmission.const import ( - CONF_LIMIT, - CONF_ORDER, - DEFAULT_LIMIT, - DEFAULT_NAME, - DEFAULT_ORDER, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, -) +from homeassistant.components.transmission.const import DEFAULT_SCAN_INTERVAL from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -153,51 +144,6 @@ async def test_options(hass): assert result["data"][CONF_SCAN_INTERVAL] == 10 -async def test_import(hass, api): - """Test import step.""" - flow = init_config_flow(hass) - - # import with minimum fields only - result = await flow.async_step_import( - { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: HOST, - CONF_PORT: DEFAULT_PORT, - CONF_SCAN_INTERVAL: timedelta(seconds=DEFAULT_SCAN_INTERVAL), - CONF_LIMIT: DEFAULT_LIMIT, - CONF_ORDER: DEFAULT_ORDER, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_NAME] == DEFAULT_NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == DEFAULT_PORT - assert result["data"][CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL - - # import with all - result = await flow.async_step_import( - { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_PORT: PORT, - CONF_SCAN_INTERVAL: timedelta(seconds=SCAN_INTERVAL), - CONF_LIMIT: DEFAULT_LIMIT, - CONF_ORDER: DEFAULT_ORDER, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_PORT] == PORT - assert result["data"][CONF_SCAN_INTERVAL] == SCAN_INTERVAL - - async def test_host_already_configured(hass, api): """Test host is already configured.""" entry = MockConfigEntry( @@ -311,3 +257,99 @@ async def test_error_on_unknown_error(hass, unknown_error): ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_success(hass, api): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=transmission.DOMAIN, + data=MOCK_ENTRY, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_ENTRY, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {CONF_USERNAME: USERNAME} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_failed(hass, auth_error): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=transmission.DOMAIN, + data=MOCK_ENTRY, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_ENTRY, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-wrong-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == { + CONF_PASSWORD: "invalid_auth", + } + + +async def test_reauth_failed_conn_error(hass, conn_error): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=transmission.DOMAIN, + data=MOCK_ENTRY, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_ENTRY, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-wrong-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index 78fddc5be86..c3dc924c54e 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -6,7 +6,7 @@ import pytest from transmissionrpc.error import TransmissionError from homeassistant.components import transmission -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, mock_coro @@ -105,7 +105,7 @@ async def test_setup_failed(hass): with patch( "transmissionrpc.Client", side_effect=TransmissionError("401: Unauthorized") - ): + ), pytest.raises(ConfigEntryAuthFailed): assert await transmission.async_setup_entry(hass, entry) is False diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 3986e4cd5a3..51cef190e2f 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -3,94 +3,43 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from ipaddress import IPv4Address import json from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( NVR, + Bootstrap, Camera, Chime, Doorlock, Light, Liveview, - ProtectAdoptableDeviceModel, Sensor, + SmartDetectObjectType, + VideoMode, Viewer, WSSubscriptionMessage, ) from homeassistant.components.unifiprotect.const import DOMAIN -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import EntityDescription +from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from . import _patch_discovery +from .utils import MockUFPFixture -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import MockConfigEntry, load_fixture MAC_ADDR = "aa:bb:cc:dd:ee:ff" -@dataclass -class MockBootstrap: - """Mock for Bootstrap.""" - - nvr: NVR - cameras: dict[str, Any] - lights: dict[str, Any] - sensors: dict[str, Any] - viewers: dict[str, Any] - liveviews: dict[str, Any] - events: dict[str, Any] - doorlocks: dict[str, Any] - chimes: dict[str, Any] - - def reset_objects(self) -> None: - """Reset all devices on bootstrap for tests.""" - self.cameras = {} - self.lights = {} - self.sensors = {} - self.viewers = {} - self.liveviews = {} - self.events = {} - self.doorlocks = {} - self.chimes = {} - - def process_ws_packet(self, msg: WSSubscriptionMessage) -> None: - """Fake process method for tests.""" - pass - - def unifi_dict(self) -> dict[str, Any]: - """Return UniFi formatted dict representation of the NVR.""" - return { - "nvr": self.nvr.unifi_dict(), - "cameras": [c.unifi_dict() for c in self.cameras.values()], - "lights": [c.unifi_dict() for c in self.lights.values()], - "sensors": [c.unifi_dict() for c in self.sensors.values()], - "viewers": [c.unifi_dict() for c in self.viewers.values()], - "liveviews": [c.unifi_dict() for c in self.liveviews.values()], - "doorlocks": [c.unifi_dict() for c in self.doorlocks.values()], - "chimes": [c.unifi_dict() for c in self.chimes.values()], - } - - -@dataclass -class MockEntityFixture: - """Mock for NVR.""" - - entry: MockConfigEntry - api: Mock - - -@pytest.fixture(name="mock_nvr") -def mock_nvr_fixture(): +@pytest.fixture(name="nvr") +def mock_nvr(): """Mock UniFi Protect Camera device.""" data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) @@ -104,7 +53,7 @@ def mock_nvr_fixture(): NVR.__config__.validate_assignment = True -@pytest.fixture(name="mock_ufp_config_entry") +@pytest.fixture(name="ufp_config_entry") def mock_ufp_config_entry(): """Mock the unifiprotect config entry.""" @@ -122,8 +71,8 @@ def mock_ufp_config_entry(): ) -@pytest.fixture(name="mock_old_nvr") -def mock_old_nvr_fixture(): +@pytest.fixture(name="old_nvr") +def old_nvr(): """Mock UniFi Protect Camera device.""" data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) @@ -131,177 +80,257 @@ def mock_old_nvr_fixture(): return NVR.from_unifi_dict(**data) -@pytest.fixture(name="mock_bootstrap") -def mock_bootstrap_fixture(mock_nvr: NVR): +@pytest.fixture(name="bootstrap") +def bootstrap_fixture(nvr: NVR): """Mock Bootstrap fixture.""" - return MockBootstrap( - nvr=mock_nvr, - cameras={}, - lights={}, - sensors={}, - viewers={}, - liveviews={}, - events={}, - doorlocks={}, - chimes={}, - ) + data = json.loads(load_fixture("sample_bootstrap.json", integration=DOMAIN)) + data["nvr"] = nvr + data["cameras"] = [] + data["lights"] = [] + data["sensors"] = [] + data["viewers"] = [] + data["liveviews"] = [] + data["events"] = [] + data["doorlocks"] = [] + data["chimes"] = [] + + return Bootstrap.from_unifi_dict(**data) -@pytest.fixture -def mock_client(mock_bootstrap: MockBootstrap): +@pytest.fixture(name="ufp_client") +def mock_ufp_client(bootstrap: Bootstrap): """Mock ProtectApiClient for testing.""" client = Mock() - client.bootstrap = mock_bootstrap + client.bootstrap = bootstrap - nvr = mock_bootstrap.nvr + nvr = client.bootstrap.nvr nvr._api = client + client.bootstrap._api = client client.base_url = "https://127.0.0.1" client.connection_host = IPv4Address("127.0.0.1") client.get_nvr = AsyncMock(return_value=nvr) - client.update = AsyncMock(return_value=mock_bootstrap) + client.update = AsyncMock(return_value=bootstrap) client.async_disconnect_ws = AsyncMock() - - def subscribe(ws_callback: Callable[[WSSubscriptionMessage], None]) -> Any: - client.ws_subscription = ws_callback - - return Mock() - - client.subscribe_websocket = subscribe return client -@pytest.fixture +@pytest.fixture(name="ufp") def mock_entry( - hass: HomeAssistant, - mock_ufp_config_entry: MockConfigEntry, - mock_client, # pylint: disable=redefined-outer-name + hass: HomeAssistant, ufp_config_entry: MockConfigEntry, ufp_client: ProtectApiClient ): """Mock ProtectApiClient for testing.""" with _patch_discovery(no_device=True), patch( "homeassistant.components.unifiprotect.ProtectApiClient" ) as mock_api: - mock_ufp_config_entry.add_to_hass(hass) + ufp_config_entry.add_to_hass(hass) - mock_api.return_value = mock_client + mock_api.return_value = ufp_client - yield MockEntityFixture(mock_ufp_config_entry, mock_client) + ufp = MockUFPFixture(ufp_config_entry, ufp_client) + + def subscribe(ws_callback: Callable[[WSSubscriptionMessage], None]) -> Any: + ufp.ws_subscription = ws_callback + return Mock() + + ufp_client.subscribe_websocket = subscribe + yield ufp @pytest.fixture -def mock_liveview(): +def liveview(): """Mock UniFi Protect Liveview.""" data = json.loads(load_fixture("sample_liveview.json", integration=DOMAIN)) return Liveview.from_unifi_dict(**data) -@pytest.fixture -def mock_camera(): +@pytest.fixture(name="camera") +def camera_fixture(fixed_now: datetime): """Mock UniFi Protect Camera device.""" + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_camera.json", integration=DOMAIN)) - return Camera.from_unifi_dict(**data) + camera = Camera.from_unifi_dict(**data) + camera.last_motion = fixed_now - timedelta(hours=1) + + yield camera + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_all") +def camera_all_fixture(camera: Camera): + """Mock UniFi Protect Camera device.""" + + all_camera = camera.copy() + all_camera.channels = [all_camera.channels[0].copy()] + + medium_channel = all_camera.channels[0].copy() + medium_channel.name = "Medium" + medium_channel.id = 1 + medium_channel.rtsp_alias = "test_medium_alias" + all_camera.channels.append(medium_channel) + + low_channel = all_camera.channels[0].copy() + low_channel.name = "Low" + low_channel.id = 2 + low_channel.rtsp_alias = "test_medium_alias" + all_camera.channels.append(low_channel) + + return all_camera + + +@pytest.fixture(name="doorbell") +def doorbell_fixture(camera: Camera, fixed_now: datetime): + """Mock UniFi Protect Camera device (with chime).""" + + doorbell = camera.copy() + doorbell.channels = [c.copy() for c in doorbell.channels] + + package_channel = doorbell.channels[0].copy() + package_channel.name = "Package Camera" + package_channel.id = 3 + package_channel.fps = 2 + package_channel.rtsp_alias = "test_package_alias" + + doorbell.channels.append(package_channel) + doorbell.feature_flags.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS] + doorbell.feature_flags.smart_detect_types = [ + SmartDetectObjectType.PERSON, + SmartDetectObjectType.VEHICLE, + ] + doorbell.feature_flags.has_hdr = True + doorbell.feature_flags.has_lcd_screen = True + doorbell.feature_flags.has_speaker = True + doorbell.feature_flags.has_privacy_mask = True + doorbell.feature_flags.has_chime = True + doorbell.feature_flags.has_smart_detect = True + doorbell.feature_flags.has_package_camera = True + doorbell.feature_flags.has_led_status = True + doorbell.last_ring = fixed_now - timedelta(hours=1) + return doorbell @pytest.fixture -def mock_light(): +def unadopted_camera(camera: Camera): + """Mock UniFi Protect Camera device (unadopted).""" + + no_camera = camera.copy() + no_camera.channels = [c.copy() for c in no_camera.channels] + no_camera.name = "Unadopted Camera" + no_camera.is_adopted = False + return no_camera + + +@pytest.fixture(name="light") +def light_fixture(): """Mock UniFi Protect Light device.""" + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) - return Light.from_unifi_dict(**data) + yield Light.from_unifi_dict(**data) + + Light.__config__.validate_assignment = True @pytest.fixture -def mock_viewer(): +def unadopted_light(light: Light): + """Mock UniFi Protect Light device (unadopted).""" + + no_light = light.copy() + no_light.name = "Unadopted Light" + no_light.is_adopted = False + return no_light + + +@pytest.fixture +def viewer(): """Mock UniFi Protect Viewport device.""" + # disable pydantic validation so mocking can happen + Viewer.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_viewport.json", integration=DOMAIN)) - return Viewer.from_unifi_dict(**data) + yield Viewer.from_unifi_dict(**data) + + Viewer.__config__.validate_assignment = True -@pytest.fixture -def mock_sensor(): +@pytest.fixture(name="sensor") +def sensor_fixture(fixed_now: datetime): """Mock UniFi Protect Sensor device.""" + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_sensor.json", integration=DOMAIN)) - return Sensor.from_unifi_dict(**data) + sensor: Sensor = Sensor.from_unifi_dict(**data) + sensor.motion_detected_at = fixed_now - timedelta(hours=1) + sensor.open_status_changed_at = fixed_now - timedelta(hours=1) + sensor.alarm_triggered_at = fixed_now - timedelta(hours=1) + yield sensor + + Sensor.__config__.validate_assignment = True -@pytest.fixture -def mock_doorlock(): +@pytest.fixture(name="sensor_all") +def csensor_all_fixture(sensor: Sensor): + """Mock UniFi Protect Sensor device.""" + + all_sensor = sensor.copy() + all_sensor.light_settings.is_enabled = True + all_sensor.humidity_settings.is_enabled = True + all_sensor.temperature_settings.is_enabled = True + all_sensor.alarm_settings.is_enabled = True + all_sensor.led_settings.is_enabled = True + all_sensor.motion_settings.is_enabled = True + + return all_sensor + + +@pytest.fixture(name="doorlock") +def doorlock_fixture(): """Mock UniFi Protect Doorlock device.""" + # disable pydantic validation so mocking can happen + Doorlock.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_doorlock.json", integration=DOMAIN)) - return Doorlock.from_unifi_dict(**data) + yield Doorlock.from_unifi_dict(**data) + + Doorlock.__config__.validate_assignment = True @pytest.fixture -def mock_chime(): +def unadopted_doorlock(doorlock: Doorlock): + """Mock UniFi Protect Light device (unadopted).""" + + no_doorlock = doorlock.copy() + no_doorlock.name = "Unadopted Lock" + no_doorlock.is_adopted = False + return no_doorlock + + +@pytest.fixture +def chime(): """Mock UniFi Protect Chime device.""" + # disable pydantic validation so mocking can happen + Chime.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_chime.json", integration=DOMAIN)) - return Chime.from_unifi_dict(**data) + yield Chime.from_unifi_dict(**data) + + Chime.__config__.validate_assignment = True -@pytest.fixture -def now(): +@pytest.fixture(name="fixed_now") +def fixed_now_fixture(): """Return datetime object that will be consistent throughout test.""" return dt_util.utcnow() - - -async def time_changed(hass: HomeAssistant, seconds: int) -> None: - """Trigger time changed.""" - next_update = dt_util.utcnow() + timedelta(seconds) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() - - -async def enable_entity( - hass: HomeAssistant, entry_id: str, entity_id: str -) -> er.RegistryEntry: - """Enable a disabled entity.""" - entity_registry = er.async_get(hass) - - updated_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) - assert not updated_entity.disabled - await hass.config_entries.async_reload(entry_id) - await hass.async_block_till_done() - - return updated_entity - - -def assert_entity_counts( - hass: HomeAssistant, platform: Platform, total: int, enabled: int -) -> None: - """Assert entity counts for a given platform.""" - - entity_registry = er.async_get(hass) - - entities = [ - e for e in entity_registry.entities if split_entity_id(e)[0] == platform.value - ] - - assert len(entities) == total - assert len(hass.states.async_all(platform.value)) == enabled - - -def ids_from_device_description( - platform: Platform, - device: ProtectAdoptableDeviceModel, - description: EntityDescription, -) -> tuple[str, str]: - """Return expected unique_id and entity_id for a give platform/device/description combination.""" - - entity_name = ( - device.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") - ) - description_entity_name = ( - description.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") - ) - - unique_id = f"{device.id}_{description.key}" - entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" - - return unique_id, entity_id diff --git a/tests/components/unifiprotect/fixtures/sample_bootstrap.json b/tests/components/unifiprotect/fixtures/sample_bootstrap.json new file mode 100644 index 00000000000..2b7326831eb --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_bootstrap.json @@ -0,0 +1,633 @@ +{ + "authUserId": "4c5f03a8c8bd48ad8e066285", + "accessKey": "8528571101220:340ff666bffb58bc404b859a:8f3f41a7b180b1ff7463fe4f7f13b528ac3d28668f25d0ecaa30c8e7888559e782b38d4335b40861030b75126eb7cea8385f3f9ab59dfa9a993e50757c277053", + "users": [ + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": true, + "enableNotifications": false, + "settings": { + "flags": {} + }, + "groups": ["b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "custom", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [ + { + "inheritFromParent": true, + "motion": [], + "person": [], + "vehicle": [], + "camera": "61b3f5c7033ea703e7000424", + "trigger": { + "when": "always", + "location": "away", + "schedules": [] + } + } + ], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "fe4c12ae2c1348edb7854e2f", + "hasAcceptedInvite": true, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": { + "firstName": "Qpvfly", + "lastName": "Ikjzilt", + "email": "QhoFvCv@example.com", + "profileImg": null, + "user": "fe4c12ae2c1348edb7854e2f", + "id": "9efc4511-4539-4402-9581-51cee8b65cf5", + "cloudId": "9efc4511-4539-4402-9581-51cee8b65cf5", + "name": "Qpvfly Ikjzilt", + "modelKey": "cloudIdentity" + }, + "name": "Qpvfly Ikjzilt", + "firstName": "Qpvfly", + "lastName": "Ikjzilt", + "email": "QhoFvCv@example.com", + "localUsername": "QhoFvCv", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": null, + "groups": ["a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "dcaef9cb8aed05c7db658a46", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Uxqg Wcbz", + "firstName": "Uxqg", + "lastName": "Wcbz", + "email": "epHDEhE@example.com", + "localUsername": "epHDEhE", + "modelKey": "user" + }, + { + "permissions": [ + "liveview:*:d65bb41c14d6aa92bfa4a6d1", + "liveview:*:49bbb5005424a0d35152671a", + "liveview:*:b28c38f1220f6b43f3930dff", + "liveview:*:b9861b533a87ea639fa4d438" + ], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": { + "flags": {}, + "web": { + "dewarp": { + "61ddb66b018e2703e7008c19": { + "dewarp": false, + "state": { + "pan": 0, + "tilt": -1.5707963267948966, + "zoom": 1.5707963267948966, + "panning": 0, + "tilting": 0 + } + } + }, + "liveview.includeGlobal": true, + "elements.events_viewmode": "grid", + "elements.viewmode": "list" + } + }, + "groups": ["b061186823695fb901973177"], + "location": { + "isAway": true, + "latitude": null, + "longitude": null + }, + "alertRules": [], + "notificationsV2": { + "state": "custom", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [ + { + "inheritFromParent": true, + "motion": [], + "camera": "61b3f5c703d2a703e7000427", + "trigger": { + "when": "always", + "location": "away", + "schedules": [] + } + }, + { + "inheritFromParent": true, + "motion": [], + "person": [], + "vehicle": [], + "camera": "61b3f5c7033ea703e7000424", + "trigger": { + "when": "always", + "location": "away", + "schedules": [] + } + } + ], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "4c5f03a8c8bd48ad8e066285", + "hasAcceptedInvite": false, + "allPermissions": [ + "liveview:*:d65bb41c14d6aa92bfa4a6d1", + "liveview:*:49bbb5005424a0d35152671a", + "liveview:*:b28c38f1220f6b43f3930dff", + "liveview:*:b9861b533a87ea639fa4d438", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Ptcmsdo Tfiyoep", + "firstName": "Ptcmsdo", + "lastName": "Tfiyoep", + "email": "EQAoXL@example.com", + "localUsername": "EQAoXL", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": { + "flags": {}, + "web": { + "dewarp": { + "61c4d1db02c82a03e700429c": { + "dewarp": false, + "state": { + "pan": 0, + "tilt": 0, + "zoom": 1.5707963267948966, + "panning": 0, + "tilting": 0 + } + } + }, + "liveview.includeGlobal": true + } + }, + "groups": ["a7f3b2eb71b4c4e56f1f45ac"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "bc3dd633553907952a6fe20d", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*" + ], + "cloudAccount": null, + "name": "Evdxou Zgyv", + "firstName": "Evdxou", + "lastName": "Zgyv", + "email": "FMZuD@example.com", + "localUsername": "FMZuD", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": null, + "groups": ["a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "adec5334b69f56f6a6c47520", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Qpv Elqfgq", + "firstName": "Qpv", + "lastName": "Elqfgq", + "email": "xdr@example.com", + "localUsername": "xdr", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": null, + "groups": ["a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "8593657a25b7826a4288b6af", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Sgpy Ooevsme", + "firstName": "Sgpy", + "lastName": "Ooevsme", + "email": "WQJNT@example.com", + "localUsername": "WQJNT", + "modelKey": "user" + }, + { + "permissions": [], + "isOwner": false, + "enableNotifications": false, + "groups": ["a7f3b2eb71b4c4e56f1f45ac"], + "alertRules": [], + "notificationsV2": { + "state": "off", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "abf647aed3650a781ceba13f", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*" + ], + "cloudAccount": null, + "name": "Yiiyq Glx", + "firstName": "Yiiyq", + "lastName": "Glx", + "email": "fBjmm@example.com", + "localUsername": "fBjmm", + "modelKey": "user" + } + ], + "groups": [ + { + "name": "Kubw Xnbb", + "permissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "type": "preset", + "isDefault": true, + "id": "b061186823695fb901973177", + "modelKey": "group" + }, + { + "name": "Pmbrvp Wyzqs", + "permissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*" + ], + "type": "preset", + "isDefault": false, + "id": "a7f3b2eb71b4c4e56f1f45ac", + "modelKey": "group" + } + ], + "schedules": [], + "legacyUFVs": [], + "lastUpdateId": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", + "displays": [], + "bridges": [ + { + "mac": "A28D0DB15AE1", + "host": "192.168.231.68", + "connectionHost": "192.168.102.63", + "type": "UFP-UAP-B", + "name": "Sffde Gxcaqe", + "upSince": 1639807977891, + "uptime": 3247782, + "lastSeen": 1643055759891, + "connectedSince": 1642374159304, + "state": "CONNECTED", + "hardwareRevision": 19, + "firmwareVersion": "0.3.1", + "latestFirmwareVersion": null, + "firmwareBuild": null, + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "wiredConnectionState": { + "phyRate": null + }, + "id": "1f5a055254fb9169d7536fb9", + "isConnected": true, + "platform": "mt7621", + "modelKey": "bridge" + }, + { + "mac": "C65C557CCA95", + "host": "192.168.87.68", + "connectionHost": "192.168.102.63", + "type": "UFP-UAP-B", + "name": "Axiwj Bbd", + "upSince": 1641257260772, + "uptime": null, + "lastSeen": 1643052750862, + "connectedSince": 1643052754695, + "state": "CONNECTED", + "hardwareRevision": 19, + "firmwareVersion": "0.3.1", + "latestFirmwareVersion": null, + "firmwareBuild": null, + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "wiredConnectionState": { + "phyRate": null + }, + "id": "e6901e3665a4c0eab0d9c1a5", + "isConnected": true, + "platform": "mt7621", + "modelKey": "bridge" + } + ] +} diff --git a/tests/components/unifiprotect/fixtures/sample_camera.json b/tests/components/unifiprotect/fixtures/sample_camera.json index eb07c6df63b..e7ffbd0abcc 100644 --- a/tests/components/unifiprotect/fixtures/sample_camera.json +++ b/tests/components/unifiprotect/fixtures/sample_camera.json @@ -4,7 +4,7 @@ "host": "192.168.6.90", "connectionHost": "192.168.178.217", "type": "UVC G4 Instant", - "name": "Fufail Qqjx", + "name": "Test Camera", "upSince": 1640020678036, "uptime": 3203, "lastSeen": 1640023881036, @@ -20,18 +20,18 @@ "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, - "isSshEnabled": true, + "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1640021213927, - "micVolume": 100, + "micVolume": 0, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 72, - "hdrMode": true, + "hdrMode": false, "videoMode": "default", "isProbingForWifi": false, "apMac": null, @@ -57,18 +57,18 @@ } }, "videoReconfigurationInProgress": false, - "voltage": null, + "voltage": 20.0, "wiredConnectionState": { - "phyRate": null + "phyRate": 1000 }, "channels": [ { "id": 0, "videoId": "video1", - "name": "Jzi Bftu", + "name": "High", "enabled": true, "isRtspEnabled": true, - "rtspAlias": "ANOAPfoKMW7VixG1", + "rtspAlias": "test_high_alias", "width": 2688, "height": 1512, "fps": 30, @@ -83,10 +83,10 @@ { "id": 1, "videoId": "video2", - "name": "Rgcpxsf Xfwt", + "name": "Medium", "enabled": true, - "isRtspEnabled": true, - "rtspAlias": "XHXAdHVKGVEzMNTP", + "isRtspEnabled": false, + "rtspAlias": null, "width": 1280, "height": 720, "fps": 30, @@ -101,7 +101,7 @@ { "id": 2, "videoId": "video3", - "name": "Umefvk Fug", + "name": "Low", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, @@ -121,7 +121,7 @@ "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, - "wdr": 1, + "wdr": 0, "icrSensitivity": 0, "brightness": 50, "contrast": 50, @@ -161,8 +161,8 @@ "quality": 100 }, "osdSettings": { - "isNameEnabled": true, - "isDateEnabled": true, + "isNameEnabled": false, + "isDateEnabled": false, "isLogoEnabled": false, "isDebugEnabled": false }, @@ -181,7 +181,7 @@ "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, - "mode": "detections", + "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, @@ -223,8 +223,8 @@ ], "smartDetectLines": [], "stats": { - "rxBytes": 33684237, - "txBytes": 1208318620, + "rxBytes": 100, + "txBytes": 100, "wifi": { "channel": 6, "frequency": 2437, @@ -248,8 +248,8 @@ "timelapseEndLQ": 1640021765237 }, "storage": { - "used": 20401094656, - "rate": 693.424269097809 + "used": 100, + "rate": 0.1 }, "wifiQuality": 100, "wifiStrength": -35 @@ -257,7 +257,7 @@ "featureFlags": { "canAdjustIrLedLevel": false, "canMagicZoom": false, - "canOpticalZoom": false, + "canOpticalZoom": true, "canTouchFocus": false, "hasAccelerometer": true, "hasAec": true, @@ -268,15 +268,15 @@ "hasIcrSensitivity": true, "hasLdc": false, "hasLedIr": true, - "hasLedStatus": true, + "hasLedStatus": false, "hasLineIn": false, "hasMic": true, - "hasPrivacyMask": true, + "hasPrivacyMask": false, "hasRtc": false, "hasSdCard": false, - "hasSpeaker": true, + "hasSpeaker": false, "hasWifi": true, - "hasHdr": true, + "hasHdr": false, "hasAutoICROnly": true, "videoModes": ["default"], "videoModeMaxFps": [], @@ -353,14 +353,14 @@ "frequency": 2437, "phyRate": 72, "signalQuality": 100, - "signalStrength": -35, + "signalStrength": -50, "ssid": "Mortis Camera" }, "lenses": [], "id": "0de062b4f6922d489d3b312d", "isConnected": true, "platform": "sav530q", - "hasSpeaker": true, + "hasSpeaker": false, "hasWifi": true, "audioBitrate": 64000, "canManage": false, diff --git a/tests/components/unifiprotect/fixtures/sample_chime.json b/tests/components/unifiprotect/fixtures/sample_chime.json index 975cfcebaea..4a2637fc700 100644 --- a/tests/components/unifiprotect/fixtures/sample_chime.json +++ b/tests/components/unifiprotect/fixtures/sample_chime.json @@ -3,7 +3,7 @@ "host": "192.168.144.146", "connectionHost": "192.168.234.27", "type": "UP Chime", - "name": "Xaorvu Tvsv", + "name": "Test Chime", "upSince": 1651882870009, "uptime": 567870, "lastSeen": 1652450740009, diff --git a/tests/components/unifiprotect/fixtures/sample_doorlock.json b/tests/components/unifiprotect/fixtures/sample_doorlock.json index 12cd7858e9d..a2e2ba0ab89 100644 --- a/tests/components/unifiprotect/fixtures/sample_doorlock.json +++ b/tests/components/unifiprotect/fixtures/sample_doorlock.json @@ -3,7 +3,7 @@ "host": null, "connectionHost": "192.168.102.63", "type": "UFP-LOCK-R", - "name": "Wkltg Qcjxv", + "name": "Test Lock", "upSince": 1643050461849, "uptime": null, "lastSeen": 1643052750858, @@ -23,9 +23,9 @@ "canAdopt": false, "isAttemptingToConnect": false, "credentials": "955756200c7f43936df9d5f7865f058e1528945aac0f0cb27cef960eb58f17db", - "lockStatus": "CLOSING", + "lockStatus": "OPEN", "enableHomekit": false, - "autoCloseTimeMs": 15000, + "autoCloseTimeMs": 45000, "wiredConnectionState": { "phyRate": null }, diff --git a/tests/components/unifiprotect/fixtures/sample_light.json b/tests/components/unifiprotect/fixtures/sample_light.json index ed0f89f3a11..ce7de9e852c 100644 --- a/tests/components/unifiprotect/fixtures/sample_light.json +++ b/tests/components/unifiprotect/fixtures/sample_light.json @@ -3,7 +3,7 @@ "host": "192.168.10.86", "connectionHost": "192.168.178.217", "type": "UP FloodLight", - "name": "Byyfbpe Ufoka", + "name": "Test Light", "upSince": 1638128991022, "uptime": 1894890, "lastSeen": 1640023881022, @@ -19,7 +19,7 @@ "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, - "isSshEnabled": true, + "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "isPirMotionDetected": false, @@ -31,20 +31,20 @@ "phyRate": 100 }, "lightDeviceSettings": { - "isIndicatorEnabled": true, + "isIndicatorEnabled": false, "ledLevel": 6, "luxSensitivity": "medium", - "pirDuration": 120000, - "pirSensitivity": 46 + "pirDuration": 45000, + "pirSensitivity": 45 }, "lightOnSettings": { "isLedForceOn": false }, "lightModeSettings": { - "mode": "off", + "mode": "motion", "enableAt": "fulltime" }, - "camera": "193be66559c03ec5629f54cd", + "camera": null, "id": "37dd610720816cfb5c547967", "isConnected": true, "isCameraPaired": true, diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 728f92c3e32..507e75fec09 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -117,6 +117,158 @@ } ] }, + "ustorage": { + "disks": [ + { + "slot": 1, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 52, + "poweronhrs": 4242, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 2, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 52, + "poweronhrs": 4242, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 3, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 51, + "poweronhrs": 4242, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 4, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 50, + "poweronhrs": 2443, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 5, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 50, + "poweronhrs": 783, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 6, + "state": "nodisk" + }, + { + "slot": 7, + "type": "HDD", + "model": "ST16000VE002-3BR101", + "serial": "ABCD1234", + "firmware": "EV01", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 45, + "poweronhrs": 18, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + } + ], + "space": [ + { + "device": "md3", + "total_bytes": 63713403555840, + "used_bytes": 57006577086464, + "action": "expanding", + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "device": "md0", + "total_bytes": 0, + "used_bytes": 0, + "action": "syncing", + "progress": 0, + "estimate": null + } + ] + }, "tmpfs": { "available": 934204, "total": 1048576, diff --git a/tests/components/unifiprotect/fixtures/sample_sensor.json b/tests/components/unifiprotect/fixtures/sample_sensor.json index 08ce9a17be2..cbba1f7583e 100644 --- a/tests/components/unifiprotect/fixtures/sample_sensor.json +++ b/tests/components/unifiprotect/fixtures/sample_sensor.json @@ -2,7 +2,7 @@ "mac": "26DBAFF133A4", "connectionHost": "192.168.216.198", "type": "UFP-SENSE", - "name": "Egdczv Urg", + "name": "Test Sensor", "upSince": 1641256963255, "uptime": null, "lastSeen": 1641259127934, @@ -25,7 +25,7 @@ "mountType": "door", "leakDetectedAt": null, "tamperingDetectedAt": null, - "isOpened": true, + "isOpened": false, "openStatusChangedAt": 1641269036582, "alarmTriggeredAt": null, "motionDetectedAt": 1641269044824, @@ -34,53 +34,53 @@ }, "stats": { "light": { - "value": 0, + "value": 10.0, "status": "neutral" }, "humidity": { - "value": 35, + "value": 10.0, "status": "neutral" }, "temperature": { - "value": 17.23, + "value": 10.0, "status": "neutral" } }, "bluetoothConnectionState": { "signalQuality": 15, - "signalStrength": -84 + "signalStrength": -50 }, "batteryStatus": { - "percentage": 100, + "percentage": 10, "isLow": false }, "alarmSettings": { "isEnabled": false }, "lightSettings": { - "isEnabled": true, + "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 10 }, "motionSettings": { - "isEnabled": true, + "isEnabled": false, "sensitivity": 100 }, "temperatureSettings": { - "isEnabled": true, + "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 0.1 }, "humiditySettings": { - "isEnabled": true, + "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 1 }, "ledSettings": { - "isEnabled": true + "isEnabled": false }, "bridge": "61b3f5c90050a703e700042a", "camera": "2f9beb2e6f79af3c32c22d49", diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 88b42d36994..640bf81ec49 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -2,11 +2,9 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from datetime import datetime, timedelta from unittest.mock import Mock -import pytest from pyunifiprotect.data import Camera, Event, EventType, Light, MountType, Sensor from pyunifiprotect.data.nvr import EventMetadata @@ -32,204 +30,72 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, + adopt_devices, assert_entity_counts, ids_from_device_description, + init_entry, + remove_entities, ) +LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] +SENSE_SENSORS_WRITE = SENSE_SENSORS[:4] -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_camera: Camera, - now: datetime, + +async def test_binary_sensor_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera ): - """Fixture for a single camera for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_chime = True - camera_obj.last_ring = now - timedelta(hours=1) - camera_obj.is_dark = False - camera_obj.is_motion_detected = False - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a camera device.""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) - yield camera_obj - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light, now: datetime +async def test_binary_sensor_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): - """Fixture for a single light for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy(deep=True) - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.is_dark = False - light_obj.is_pir_motion_detected = False - light_obj.last_motion = now - timedelta(hours=1) - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a light device.""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) + await adopt_devices(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) - yield light_obj - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_none") -async def camera_none_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +async def test_binary_sensor_sensor_remove( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): - """Fixture for a single camera for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_chime = False - camera_obj.is_dark = False - camera_obj.is_motion_detected = False - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="sensor") -async def sensor_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, -): - """Fixture for a single sensor for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy(deep=True) - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.mount_type = MountType.DOOR - sensor_obj.is_opened = False - sensor_obj.battery_status.is_low = False - sensor_obj.is_motion_detected = False - sensor_obj.alarm_settings.is_enabled = True - sensor_obj.motion_detected_at = now - timedelta(hours=1) - sensor_obj.open_status_changed_at = now - timedelta(hours=1) - sensor_obj.alarm_triggered_at = now - timedelta(hours=1) - sensor_obj.tampering_detected_at = None - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a light device.""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) - - yield sensor_obj - - Sensor.__config__.validate_assignment = True - - -@pytest.fixture(name="sensor_none") -async def sensor_none_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, -): - """Fixture for a single sensor for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy(deep=True) - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.mount_type = MountType.LEAK - sensor_obj.battery_status.is_low = False - sensor_obj.alarm_settings.is_enabled = False - sensor_obj.tampering_detected_at = None - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - + await remove_entities(hass, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) + await adopt_devices(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) - yield sensor_obj - - Sensor.__config__.validate_assignment = True - async def test_binary_sensor_setup_light( - hass: HomeAssistant, light: Light, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test binary_sensor entity setup for light devices.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + entity_registry = er.async_get(hass) - for description in LIGHT_SENSORS: + for description in LIGHT_SENSOR_WRITE: unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, light, description ) @@ -245,15 +111,22 @@ async def test_binary_sensor_setup_light( async def test_binary_sensor_setup_camera_all( - hass: HomeAssistant, camera: Camera, now: datetime + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test binary_sensor entity setup for camera devices (all features).""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + entity_registry = er.async_get(hass) description = CAMERA_SENSORS[0] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -268,7 +141,7 @@ async def test_binary_sensor_setup_camera_all( # Is Dark description = CAMERA_SENSORS[1] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -283,7 +156,7 @@ async def test_binary_sensor_setup_camera_all( # Motion description = MOTION_SENSORS[0] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -298,16 +171,19 @@ async def test_binary_sensor_setup_camera_all( async def test_binary_sensor_setup_camera_none( - hass: HomeAssistant, - camera_none: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test binary_sensor entity setup for camera devices (no features).""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + entity_registry = er.async_get(hass) description = CAMERA_SENSORS[1] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera_none, description + Platform.BINARY_SENSOR, camera, description ) entity = entity_registry.async_get(entity_id) @@ -321,15 +197,18 @@ async def test_binary_sensor_setup_camera_none( async def test_binary_sensor_setup_sensor( - hass: HomeAssistant, sensor: Sensor, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test binary_sensor entity setup for sensor devices.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + entity_registry = er.async_get(hass) - for description in SENSE_SENSORS: + for description in SENSE_SENSORS_WRITE: unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, description + Platform.BINARY_SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -343,10 +222,14 @@ async def test_binary_sensor_setup_sensor( async def test_binary_sensor_setup_sensor_none( - hass: HomeAssistant, sensor_none: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor ): """Test binary_sensor entity setup for sensor with most sensors disabled.""" + sensor.mount_type = MountType.LEAK + await init_entry(hass, ufp, [sensor]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + entity_registry = er.async_get(hass) expected = [ @@ -355,9 +238,9 @@ async def test_binary_sensor_setup_sensor_none( STATE_UNAVAILABLE, STATE_OFF, ] - for index, description in enumerate(SENSE_SENSORS): + for index, description in enumerate(SENSE_SENSORS_WRITE): unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_none, description + Platform.BINARY_SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -366,33 +249,38 @@ async def test_binary_sensor_setup_sensor_none( state = hass.states.get(entity_id) assert state - print(entity_id) assert state.state == expected[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION async def test_binary_sensor_update_motion( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 9) + _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, MOTION_SENSORS[0] + Platform.BINARY_SENSOR, doorbell, MOTION_SENSORS[0] ) event = Event( id="test_event_id", type=EventType.MOTION, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[], smart_detect_event_ids=[], - camera_id=camera.id, + camera_id=doorbell.id, ) - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera.copy() + new_camera = doorbell.copy() new_camera.is_motion_detected = True new_camera.last_motion_event_id = event.id @@ -400,10 +288,9 @@ async def test_binary_sensor_update_motion( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -414,28 +301,30 @@ async def test_binary_sensor_update_motion( async def test_binary_sensor_update_light_motion( - hass: HomeAssistant, mock_entry: MockEntityFixture, light: Light, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, fixed_now: datetime ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, LIGHT_SENSORS[1] + Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) event_metadata = EventMetadata(light_id=light.id) event = Event( id="test_event_id", type=EventType.MOTION_LIGHT, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[], smart_detect_event_ids=[], metadata=event_metadata, - api=mock_entry.api, + api=ufp.api, ) - new_bootstrap = copy(mock_entry.api.bootstrap) new_light = light.copy() new_light.is_pir_motion_detected = True new_light.last_motion_event_id = event.id @@ -444,10 +333,9 @@ async def test_binary_sensor_update_light_motion( mock_msg.changed_data = {} mock_msg.new_obj = event - new_bootstrap.lights = {new_light.id: new_light} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.lights = {new_light.id: new_light} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -456,29 +344,30 @@ async def test_binary_sensor_update_light_motion( async def test_binary_sensor_update_mount_type_window( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] ) state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_bootstrap = copy(mock_entry.api.bootstrap) - new_sensor = sensor.copy() + new_sensor = sensor_all.copy() new_sensor.mount_type = MountType.WINDOW mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_sensor - new_bootstrap.sensors = {new_sensor.id: new_sensor} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.sensors = {new_sensor.id: new_sensor} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -487,29 +376,30 @@ async def test_binary_sensor_update_mount_type_window( async def test_binary_sensor_update_mount_type_garage( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] ) state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_bootstrap = copy(mock_entry.api.bootstrap) - new_sensor = sensor.copy() + new_sensor = sensor_all.copy() new_sensor.mount_type = MountType.GARAGE mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_sensor - new_bootstrap.sensors = {new_sensor.id: new_sensor} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.sensors = {new_sensor.id: new_sensor} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index f3b76cb7abb..a46d74e0b8e 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import AsyncMock -import pytest from pyunifiprotect.data.devices import Chime from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -12,41 +11,42 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts, enable_entity +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + enable_entity, + init_entry, + remove_entities, +) -@pytest.fixture(name="chime") -async def chime_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_chime: Chime +async def test_button_chime_remove( + hass: HomeAssistant, ufp: MockUFPFixture, chime: Chime ): - """Fixture for a single camera for testing the button platform.""" - - chime_obj = mock_chime.copy(deep=True) - chime_obj._api = mock_entry.api - chime_obj.name = "Test Chime" - - mock_entry.api.bootstrap.chimes = { - chime_obj.id: chime_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a light device.""" + await init_entry(hass, ufp, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 3, 2) + await remove_entities(hass, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 0, 0) + await adopt_devices(hass, ufp, [chime]) assert_entity_counts(hass, Platform.BUTTON, 3, 2) - - return chime_obj async def test_reboot_button( hass: HomeAssistant, - mock_entry: MockEntityFixture, + ufp: MockUFPFixture, chime: Chime, ): """Test button entity.""" - mock_entry.api.reboot_device = AsyncMock() + await init_entry(hass, ufp, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 3, 2) - unique_id = f"{chime.id}_reboot" + ufp.api.reboot_device = AsyncMock() + + unique_id = f"{chime.mac}_reboot" entity_id = "button.test_chime_reboot_device" entity_registry = er.async_get(hass) @@ -55,7 +55,7 @@ async def test_reboot_button( assert entity.disabled assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION @@ -63,19 +63,22 @@ async def test_reboot_button( await hass.services.async_call( "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - mock_entry.api.reboot_device.assert_called_once() + ufp.api.reboot_device.assert_called_once() async def test_chime_button( hass: HomeAssistant, - mock_entry: MockEntityFixture, + ufp: MockUFPFixture, chime: Chime, ): """Test button entity.""" - mock_entry.api.play_speaker = AsyncMock() + await init_entry(hass, ufp, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 3, 2) - unique_id = f"{chime.id}_play" + ufp.api.play_speaker = AsyncMock() + + unique_id = f"{chime.mac}_play" entity_id = "button.test_chime_play_chime" entity_registry = er.async_get(hass) @@ -91,4 +94,4 @@ async def test_chime_button( await hass.services.async_call( "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - mock_entry.api.play_speaker.assert_called_once() + ufp.api.play_speaker.assert_called_once() diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index d7fc2a62325..2b103e8d714 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -2,16 +2,13 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import Camera as ProtectCamera, CameraChannel, StateType from pyunifiprotect.exceptions import NvrError from homeassistant.components.camera import ( SUPPORT_STREAM, - Camera, async_get_image, async_get_stream_source, ) @@ -34,81 +31,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, + adopt_devices, assert_entity_counts, enable_entity, + init_entry, + remove_entities, time_changed, ) -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the camera platform.""" - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.channels[0].is_rtsp_enabled = True - camera_obj.channels[0].name = "High" - camera_obj.channels[1].is_rtsp_enabled = False - camera_obj.channels[2].is_rtsp_enabled = False - - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.CAMERA, 2, 1) - - return (camera_obj, "camera.test_camera_high") - - -@pytest.fixture(name="camera_package") -async def camera_package_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the camera platform.""" - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_package_camera = True - camera_obj.channels[0].is_rtsp_enabled = True - camera_obj.channels[0].name = "High" - camera_obj.channels[0].rtsp_alias = "test_high_alias" - camera_obj.channels[1].is_rtsp_enabled = False - camera_obj.channels[2].is_rtsp_enabled = False - package_channel = camera_obj.channels[0].copy(deep=True) - package_channel.is_rtsp_enabled = False - package_channel.name = "Package Camera" - package_channel.id = 3 - package_channel.fps = 2 - package_channel.rtsp_alias = "test_package_alias" - camera_obj.channels.append(package_channel) - - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.CAMERA, 3, 2) - - return (camera_obj, "camera.test_camera_package_camera") - - def validate_default_camera_entity( hass: HomeAssistant, camera_obj: ProtectCamera, @@ -119,7 +52,7 @@ def validate_default_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name}" - unique_id = f"{camera_obj.id}_{channel.id}" + unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -141,7 +74,7 @@ def validate_rtsps_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name}" - unique_id = f"{camera_obj.id}_{channel.id}" + unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -163,7 +96,7 @@ def validate_rtsp_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name} Insecure" - unique_id = f"{camera_obj.id}_{channel.id}_insecure" + unique_id = f"{camera_obj.mac}_{channel.id}_insecure" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -236,94 +169,46 @@ async def validate_no_stream_camera_state( async def test_basic_setup( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera + hass: HomeAssistant, + ufp: MockUFPFixture, + camera_all: ProtectCamera, + doorbell: ProtectCamera, ): """Test working setup of unifiprotect entry.""" - camera_high_only = mock_camera.copy(deep=True) - camera_high_only._api = mock_entry.api - camera_high_only.channels[0]._api = mock_entry.api - camera_high_only.channels[1]._api = mock_entry.api - camera_high_only.channels[2]._api = mock_entry.api + camera_high_only = camera_all.copy() + camera_high_only.channels = [c.copy() for c in camera_all.channels] camera_high_only.name = "Test Camera 1" - camera_high_only.id = "test_high" camera_high_only.channels[0].is_rtsp_enabled = True - camera_high_only.channels[0].name = "High" - camera_high_only.channels[0].rtsp_alias = "test_high_alias" camera_high_only.channels[1].is_rtsp_enabled = False camera_high_only.channels[2].is_rtsp_enabled = False - camera_medium_only = mock_camera.copy(deep=True) - camera_medium_only._api = mock_entry.api - camera_medium_only.channels[0]._api = mock_entry.api - camera_medium_only.channels[1]._api = mock_entry.api - camera_medium_only.channels[2]._api = mock_entry.api + camera_medium_only = camera_all.copy() + camera_medium_only.channels = [c.copy() for c in camera_all.channels] camera_medium_only.name = "Test Camera 2" - camera_medium_only.id = "test_medium" camera_medium_only.channels[0].is_rtsp_enabled = False camera_medium_only.channels[1].is_rtsp_enabled = True - camera_medium_only.channels[1].name = "Medium" - camera_medium_only.channels[1].rtsp_alias = "test_medium_alias" camera_medium_only.channels[2].is_rtsp_enabled = False - camera_all_channels = mock_camera.copy(deep=True) - camera_all_channels._api = mock_entry.api - camera_all_channels.channels[0]._api = mock_entry.api - camera_all_channels.channels[1]._api = mock_entry.api - camera_all_channels.channels[2]._api = mock_entry.api - camera_all_channels.name = "Test Camera 3" - camera_all_channels.id = "test_all" - camera_all_channels.channels[0].is_rtsp_enabled = True - camera_all_channels.channels[0].name = "High" - camera_all_channels.channels[0].rtsp_alias = "test_high_alias" - camera_all_channels.channels[1].is_rtsp_enabled = True - camera_all_channels.channels[1].name = "Medium" - camera_all_channels.channels[1].rtsp_alias = "test_medium_alias" - camera_all_channels.channels[2].is_rtsp_enabled = True - camera_all_channels.channels[2].name = "Low" - camera_all_channels.channels[2].rtsp_alias = "test_low_alias" + camera_all.name = "Test Camera 3" - camera_no_channels = mock_camera.copy(deep=True) - camera_no_channels._api = mock_entry.api - camera_no_channels.channels[0]._api = mock_entry.api - camera_no_channels.channels[1]._api = mock_entry.api - camera_no_channels.channels[2]._api = mock_entry.api + camera_no_channels = camera_all.copy() + camera_no_channels.channels = [c.copy() for c in camera_all.channels] camera_no_channels.name = "Test Camera 4" - camera_no_channels.id = "test_none" camera_no_channels.channels[0].is_rtsp_enabled = False - camera_no_channels.channels[0].name = "High" camera_no_channels.channels[1].is_rtsp_enabled = False camera_no_channels.channels[2].is_rtsp_enabled = False - camera_package = mock_camera.copy(deep=True) - camera_package._api = mock_entry.api - camera_package.channels[0]._api = mock_entry.api - camera_package.channels[1]._api = mock_entry.api - camera_package.channels[2]._api = mock_entry.api - camera_package.name = "Test Camera 5" - camera_package.id = "test_package" - camera_package.channels[0].is_rtsp_enabled = True - camera_package.channels[0].name = "High" - camera_package.channels[0].rtsp_alias = "test_high_alias" - camera_package.channels[1].is_rtsp_enabled = False - camera_package.channels[2].is_rtsp_enabled = False - package_channel = camera_package.channels[0].copy(deep=True) - package_channel.is_rtsp_enabled = False - package_channel.name = "Package Camera" - package_channel.id = 3 - package_channel.fps = 2 - package_channel.rtsp_alias = "test_package_alias" - camera_package.channels.append(package_channel) + doorbell.name = "Test Camera 5" - mock_entry.api.bootstrap.cameras = { - camera_high_only.id: camera_high_only, - camera_medium_only.id: camera_medium_only, - camera_all_channels.id: camera_all_channels, - camera_no_channels.id: camera_no_channels, - camera_package.id: camera_package, - } - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + devices = [ + camera_high_only, + camera_medium_only, + camera_all, + camera_no_channels, + doorbell, + ] + await init_entry(hass, ufp, devices) assert_entity_counts(hass, Platform.CAMERA, 14, 6) @@ -332,7 +217,7 @@ async def test_basic_setup( await validate_rtsps_camera_state(hass, camera_high_only, 0, entity_id) entity_id = validate_rtsp_camera_entity(hass, camera_high_only, 0) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) await validate_rtsp_camera_state(hass, camera_high_only, 0, entity_id) # test camera 2 @@ -340,32 +225,32 @@ async def test_basic_setup( await validate_rtsps_camera_state(hass, camera_medium_only, 1, entity_id) entity_id = validate_rtsp_camera_entity(hass, camera_medium_only, 1) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) await validate_rtsp_camera_state(hass, camera_medium_only, 1, entity_id) # test camera 3 - entity_id = validate_default_camera_entity(hass, camera_all_channels, 0) - await validate_rtsps_camera_state(hass, camera_all_channels, 0, entity_id) + entity_id = validate_default_camera_entity(hass, camera_all, 0) + await validate_rtsps_camera_state(hass, camera_all, 0, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 0) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_all_channels, 0, entity_id) + entity_id = validate_rtsp_camera_entity(hass, camera_all, 0) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all, 0, entity_id) - entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 1) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsps_camera_state(hass, camera_all_channels, 1, entity_id) + entity_id = validate_rtsps_camera_entity(hass, camera_all, 1) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsps_camera_state(hass, camera_all, 1, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 1) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_all_channels, 1, entity_id) + entity_id = validate_rtsp_camera_entity(hass, camera_all, 1) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all, 1, entity_id) - entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 2) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsps_camera_state(hass, camera_all_channels, 2, entity_id) + entity_id = validate_rtsps_camera_entity(hass, camera_all, 2) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsps_camera_state(hass, camera_all, 2, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 2) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_all_channels, 2, entity_id) + entity_id = validate_rtsp_camera_entity(hass, camera_all, 2) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all, 2, entity_id) # test camera 4 entity_id = validate_default_camera_entity(hass, camera_no_channels, 0) @@ -374,188 +259,213 @@ async def test_basic_setup( ) # test camera 5 - entity_id = validate_default_camera_entity(hass, camera_package, 0) - await validate_rtsps_camera_state(hass, camera_package, 0, entity_id) + entity_id = validate_default_camera_entity(hass, doorbell, 0) + await validate_rtsps_camera_state(hass, doorbell, 0, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_package, 0) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_package, 0, entity_id) + entity_id = validate_rtsp_camera_entity(hass, doorbell, 0) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, doorbell, 0, entity_id) - entity_id = validate_default_camera_entity(hass, camera_package, 3) - await validate_no_stream_camera_state( - hass, camera_package, 3, entity_id, features=0 - ) + entity_id = validate_default_camera_entity(hass, doorbell, 3) + await validate_no_stream_camera_state(hass, doorbell, 3, entity_id, features=0) -async def test_missing_channels( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera -): +async def test_adopt(hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera): """Test setting up camera with no camera channels.""" - camera = mock_camera.copy(deep=True) - camera.channels = [] + camera1 = camera.copy() + camera1.channels = [] - mock_entry.api.bootstrap.cameras = {camera.id: camera} + await init_entry(hass, ufp, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await remove_entities(hass, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + camera1.channels = [] + await adopt_devices(hass, ufp, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + camera1.channels = camera.channels + for channel in camera1.channels: + channel._api = ufp.api + + mock_msg = Mock() + mock_msg.changed_data = {"channels": camera.channels} + mock_msg.new_obj = camera1 + ufp.ws_msg(mock_msg) await hass.async_block_till_done() + assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_registry = er.async_get(hass) - - assert len(hass.states.async_all()) == 0 - assert len(entity_registry.entities) == 0 + await remove_entities(hass, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + await adopt_devices(hass, ufp, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) async def test_camera_image( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Test retrieving camera image.""" - mock_entry.api.get_camera_snapshot = AsyncMock() + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) - await async_get_image(hass, camera[1]) - mock_entry.api.get_camera_snapshot.assert_called_once() + ufp.api.get_camera_snapshot = AsyncMock() + + await async_get_image(hass, "camera.test_camera_high") + ufp.api.get_camera_snapshot.assert_called_once() async def test_package_camera_image( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera_package: tuple[Camera, str], + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: ProtectCamera ): """Test retrieving package camera image.""" - mock_entry.api.get_package_camera_snapshot = AsyncMock() + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.CAMERA, 3, 2) - await async_get_image(hass, camera_package[1]) - mock_entry.api.get_package_camera_snapshot.assert_called_once() + ufp.api.get_package_camera_snapshot = AsyncMock() + + await async_get_image(hass, "camera.test_camera_package_camera") + ufp.api.get_package_camera_snapshot.assert_called_once() async def test_camera_generic_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Tests generic entity update service.""" + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + assert await async_setup_component(hass, "homeassistant", {}) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" - mock_entry.api.update = AsyncMock(return_value=None) + ufp.api.update = AsyncMock(return_value=None) await hass.services.async_call( "homeassistant", "update_entity", - {ATTR_ENTITY_ID: camera[1]}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" async def test_camera_interval_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Interval updates updates camera entity.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + new_camera = camera.copy() new_camera.is_recording = True - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.update = AsyncMock(return_value=new_bootstrap) - mock_entry.api.bootstrap = new_bootstrap + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap) await time_changed(hass, DEFAULT_SCAN_INTERVAL) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "recording" async def test_camera_bad_interval_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Interval updates marks camera unavailable.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" # update fails - mock_entry.api.update = AsyncMock(side_effect=NvrError) + ufp.api.update = AsyncMock(side_effect=NvrError) await time_changed(hass, DEFAULT_SCAN_INTERVAL) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "unavailable" # next update succeeds - mock_entry.api.update = AsyncMock(return_value=mock_entry.api.bootstrap) + ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap) await time_changed(hass, DEFAULT_SCAN_INTERVAL) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" async def test_camera_ws_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """WS update updates camera entity.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + new_camera = camera.copy() new_camera.is_recording = True + no_camera = camera.copy() + no_camera.is_adopted = False + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_camera + ufp.ws_msg(mock_msg) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = no_camera + ufp.ws_msg(mock_msg) - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "recording" async def test_camera_ws_update_offline( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """WS updates marks camera unavailable.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" # camera goes offline - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + new_camera = camera.copy() new_camera.state = StateType.DISCONNECTED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "unavailable" # camera comes back online @@ -565,10 +475,53 @@ async def test_camera_ws_update_offline( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" + + +async def test_camera_enable_motion( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +): + """Tests generic entity update service.""" + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + camera.__fields__["set_motion_detection"] = Mock() + camera.set_motion_detection = AsyncMock() + + await hass.services.async_call( + "camera", + "enable_motion_detection", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + camera.set_motion_detection.assert_called_once_with(True) + + +async def test_camera_disable_motion( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +): + """Tests generic entity update service.""" + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + camera.__fields__["set_motion_detection"] = Mock() + camera.set_motion_detection = AsyncMock() + + await hass.services.async_call( + "camera", + "disable_motion_detection", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + camera.set_motion_detection.assert_called_once_with(False) diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 75f08acb37c..3d561f2d781 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -6,7 +6,7 @@ import socket from unittest.mock import patch import pytest -from pyunifiprotect import NotAuthorized, NvrError +from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import NVR from homeassistant import config_entries @@ -61,7 +61,7 @@ UNIFI_DISCOVERY_DICT = asdict(UNIFI_DISCOVERY) UNIFI_DISCOVERY_DICT_PARTIAL = asdict(UNIFI_DISCOVERY_PARTIAL) -async def test_form(hass: HomeAssistant, mock_nvr: NVR) -> None: +async def test_form(hass: HomeAssistant, nvr: NVR) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -71,7 +71,7 @@ async def test_form(hass: HomeAssistant, mock_nvr: NVR) -> None: with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -99,7 +99,7 @@ async def test_form(hass: HomeAssistant, mock_nvr: NVR) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_version_too_old(hass: HomeAssistant, mock_old_nvr: NVR) -> None: +async def test_form_version_too_old(hass: HomeAssistant, old_nvr: NVR) -> None: """Test we handle the version being too old.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -107,7 +107,7 @@ async def test_form_version_too_old(hass: HomeAssistant, mock_old_nvr: NVR) -> N with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_old_nvr, + return_value=old_nvr, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -168,7 +168,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_reauth_auth(hass: HomeAssistant, mock_nvr: NVR) -> None: +async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: """Test we handle reauth auth.""" mock_config = MockConfigEntry( domain=DOMAIN, @@ -217,7 +217,7 @@ async def test_form_reauth_auth(hass: HomeAssistant, mock_nvr: NVR) -> None: with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -231,7 +231,7 @@ async def test_form_reauth_auth(hass: HomeAssistant, mock_nvr: NVR) -> None: assert result3["reason"] == "reauth_successful" -async def test_form_options(hass: HomeAssistant, mock_client) -> None: +async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) -> None: """Test we handle options flows.""" mock_config = MockConfigEntry( domain=DOMAIN, @@ -251,7 +251,7 @@ async def test_form_options(hass: HomeAssistant, mock_client) -> None: with _patch_discovery(), patch( "homeassistant.components.unifiprotect.ProtectApiClient" ) as mock_api: - mock_api.return_value = mock_client + mock_api.return_value = ufp_client await hass.config_entries.async_setup(mock_config.entry_id) await hass.async_block_till_done() @@ -300,7 +300,7 @@ async def test_discovered_by_ssdp_or_dhcp( async def test_discovered_by_unifi_discovery_direct_connect( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, nvr: NVR ) -> None: """Test a discovery from unifi-discovery.""" @@ -324,7 +324,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -352,7 +352,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( async def test_discovered_by_unifi_discovery_direct_connect_updated( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery updates the direct connect host.""" mock_config = MockConfigEntry( @@ -384,7 +384,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_using_direct_connect( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery updates the host but not direct connect if its not in use.""" mock_config = MockConfigEntry( @@ -419,7 +419,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_still_online( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery does not update the ip unless the console at the old ip is offline.""" mock_config = MockConfigEntry( @@ -454,7 +454,7 @@ async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_ async def test_discovered_host_not_updated_if_existing_is_a_hostname( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test we only update the host if its an ip address from discovery.""" mock_config = MockConfigEntry( @@ -484,9 +484,7 @@ async def test_discovered_host_not_updated_if_existing_is_a_hostname( assert mock_config.data[CONF_HOST] == "a.hostname" -async def test_discovered_by_unifi_discovery( - hass: HomeAssistant, mock_nvr: NVR -) -> None: +async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> None: """Test a discovery from unifi-discovery.""" with _patch_discovery(): @@ -509,7 +507,7 @@ async def test_discovered_by_unifi_discovery( with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - side_effect=[NotAuthorized, mock_nvr], + side_effect=[NotAuthorized, nvr], ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -537,7 +535,7 @@ async def test_discovered_by_unifi_discovery( async def test_discovered_by_unifi_discovery_partial( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, nvr: NVR ) -> None: """Test a discovery from unifi-discovery partial.""" @@ -561,7 +559,7 @@ async def test_discovered_by_unifi_discovery_partial( with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -589,7 +587,7 @@ async def test_discovered_by_unifi_discovery_partial( async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface.""" mock_config = MockConfigEntry( @@ -619,7 +617,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_ip_matches( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface when the ip matches.""" mock_config = MockConfigEntry( @@ -649,7 +647,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface when direct connect domain resolves to host ip.""" mock_config = MockConfigEntry( @@ -687,7 +685,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_fails( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, nvr: NVR ) -> None: """Test we can still configure if the resolver fails.""" mock_config = MockConfigEntry( @@ -730,7 +728,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -758,7 +756,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_no_result( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface when direct connect domain resolve has no result.""" mock_config = MockConfigEntry( @@ -791,7 +789,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa assert result["reason"] == "already_configured" -async def test_discovery_can_be_ignored(hass: HomeAssistant, mock_nvr: NVR) -> None: +async def test_discovery_can_be_ignored(hass: HomeAssistant) -> None: """Test a discovery can be ignored.""" mock_config = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index b58e164e913..a0ed8f0d882 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -4,53 +4,44 @@ from pyunifiprotect.data import NVR, Light from homeassistant.core import HomeAssistant -from .conftest import MockEntityFixture +from .utils import MockUFPFixture, init_entry from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_diagnostics( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light, hass_client + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, hass_client ): """Test generating diagnostics for a config entry.""" - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - light1.id = "lightid1" + await init_entry(hass, ufp, [light]) - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, ufp.entry) - diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_entry.entry) - - nvr_obj: NVR = mock_entry.api.bootstrap.nvr + nvr: NVR = ufp.api.bootstrap.nvr # validate some of the data assert "nvr" in diag and isinstance(diag["nvr"], dict) - nvr = diag["nvr"] + nvr_dict = diag["nvr"] # should have been anonymized - assert nvr["id"] != nvr_obj.id - assert nvr["mac"] != nvr_obj.mac - assert nvr["host"] != str(nvr_obj.host) + assert nvr_dict["id"] != nvr.id + assert nvr_dict["mac"] != nvr.mac + assert nvr_dict["host"] != str(nvr.host) # should have been kept - assert nvr["firmwareVersion"] == nvr_obj.firmware_version - assert nvr["version"] == str(nvr_obj.version) - assert nvr["type"] == nvr_obj.type + assert nvr_dict["firmwareVersion"] == nvr.firmware_version + assert nvr_dict["version"] == str(nvr.version) + assert nvr_dict["type"] == nvr.type assert ( "lights" in diag and isinstance(diag["lights"], list) and len(diag["lights"]) == 1 ) - light = diag["lights"][0] + light_dict = diag["lights"][0] # should have been anonymized - assert light["id"] != light1.id - assert light["name"] != light1.mac - assert light["mac"] != light1.mac - assert light["host"] != str(light1.host) + assert light_dict["id"] != light.id + assert light_dict["name"] != light.mac + assert light_dict["mac"] != light.mac + assert light_dict["host"] != str(light.host) # should have been kept - assert light["firmwareVersion"] == light1.firmware_version - assert light["type"] == light1.type + assert light_dict["firmwareVersion"] == light.firmware_version + assert light_dict["type"] == light.type diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 95c2ee0b511..f6f0645df18 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -2,54 +2,71 @@ # pylint: disable=protected-access from __future__ import annotations +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, patch -from pyunifiprotect import NotAuthorized, NvrError -from pyunifiprotect.data import NVR, Light +import aiohttp +from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient +from pyunifiprotect.data import NVR, Bootstrap, Light from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import Platform 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 _patch_discovery -from .conftest import MockBootstrap, MockEntityFixture +from .utils import MockUFPFixture, init_entry from tests.common import MockConfigEntry -async def test_setup(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def remove_device( + ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str +) -> bool: + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture): """Test working setup of unifiprotect entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac async def test_setup_multiple( hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_client, - mock_bootstrap: MockBootstrap, + ufp: MockUFPFixture, + bootstrap: Bootstrap, ): """Test working setup of unifiprotect entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac - nvr = mock_bootstrap.nvr - nvr._api = mock_client + nvr = bootstrap.nvr + nvr._api = ufp.api nvr.mac = "A1E00C826983" nvr.id - mock_client.get_nvr = AsyncMock(return_value=nvr) + ufp.api.get_nvr = AsyncMock(return_value=nvr) with patch("homeassistant.components.unifiprotect.ProtectApiClient") as mock_api: mock_config = MockConfigEntry( @@ -66,258 +83,170 @@ async def test_setup_multiple( ) mock_config.add_to_hass(hass) - mock_api.return_value = mock_client + mock_api.return_value = ufp.api await hass.config_entries.async_setup(mock_config.entry_id) await hass.async_block_till_done() assert mock_config.state == ConfigEntryState.LOADED - assert mock_client.update.called - assert mock_config.unique_id == mock_client.bootstrap.nvr.mac + assert ufp.api.update.called + assert mock_config.unique_id == ufp.api.bootstrap.nvr.mac -async def test_reload(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_reload(hass: HomeAssistant, ufp: MockUFPFixture): """Test updating entry reload entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED + assert ufp.entry.state == ConfigEntryState.LOADED - options = dict(mock_entry.entry.options) + options = dict(ufp.entry.options) options[CONF_DISABLE_RTSP] = True - hass.config_entries.async_update_entry(mock_entry.entry, options=options) + hass.config_entries.async_update_entry(ufp.entry, options=options) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.async_disconnect_ws.called + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.async_disconnect_ws.called -async def test_unload(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): """Test unloading of unifiprotect entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED + await init_entry(hass, ufp, [light]) + assert ufp.entry.state == ConfigEntryState.LOADED - await hass.config_entries.async_unload(mock_entry.entry.entry_id) - assert mock_entry.entry.state == ConfigEntryState.NOT_LOADED - assert mock_entry.api.async_disconnect_ws.called + await hass.config_entries.async_unload(ufp.entry.entry_id) + assert ufp.entry.state == ConfigEntryState.NOT_LOADED + assert ufp.api.async_disconnect_ws.called -async def test_setup_too_old( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_old_nvr: NVR -): +async def test_setup_too_old(hass: HomeAssistant, ufp: MockUFPFixture, old_nvr: NVR): """Test setup of unifiprotect entry with too old of version of UniFi Protect.""" - mock_entry.api.get_nvr.return_value = mock_old_nvr + ufp.api.get_nvr.return_value = old_nvr - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR - assert not mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_ERROR + assert not ufp.api.update.called -async def test_setup_failed_update(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_setup_failed_update(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with failed update.""" - mock_entry.api.update = AsyncMock(side_effect=NvrError) + ufp.api.update = AsyncMock(side_effect=NvrError) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY - assert mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY + assert ufp.api.update.called -async def test_setup_failed_update_reauth( - hass: HomeAssistant, mock_entry: MockEntityFixture -): +async def test_setup_failed_update_reauth(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with update that gives unauthroized error.""" - mock_entry.api.update = AsyncMock(side_effect=NotAuthorized) + ufp.api.update = AsyncMock(side_effect=NotAuthorized) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY - assert mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY + assert ufp.api.update.called -async def test_setup_failed_error(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_setup_failed_error(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with generic error.""" - mock_entry.api.get_nvr = AsyncMock(side_effect=NvrError) + ufp.api.get_nvr = AsyncMock(side_effect=NvrError) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY - assert not mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY + assert not ufp.api.update.called -async def test_setup_failed_auth(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with unauthorized error.""" - mock_entry.api.get_nvr = AsyncMock(side_effect=NotAuthorized) + ufp.api.get_nvr = AsyncMock(side_effect=NotAuthorized) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR - assert not mock_entry.api.update.called + await hass.config_entries.async_setup(ufp.entry.entry_id) + assert ufp.entry.state == ConfigEntryState.SETUP_ERROR + assert not ufp.api.update.called async def test_setup_starts_discovery( - hass: HomeAssistant, mock_ufp_config_entry: ConfigEntry, mock_client + hass: HomeAssistant, ufp_config_entry: ConfigEntry, ufp_client: ProtectApiClient ): """Test setting up will start discovery.""" with _patch_discovery(), patch( "homeassistant.components.unifiprotect.ProtectApiClient" ) as mock_api: - mock_ufp_config_entry.add_to_hass(hass) - mock_api.return_value = mock_client - mock_entry = MockEntityFixture(mock_ufp_config_entry, mock_client) + ufp_config_entry.add_to_hass(hass) + mock_api.return_value = ufp_client + ufp = MockUFPFixture(ufp_config_entry, ufp_client) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED + assert ufp.entry.state == ConfigEntryState.LOADED await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 -async def test_migrate_reboot_button( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Test migrating unique ID of reboot button.""" +async def test_device_remove_devices( + hass: HomeAssistant, + ufp: MockUFPFixture, + light: Light, + hass_ws_client: Callable[ + [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] + ], +) -> None: + """Test we can only remove a device that no longer exists.""" - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - light1.id = "lightid1" + await init_entry(hass, ufp, [light]) + assert await async_setup_component(hass, "config", {}) + entity_id = "light.test_light" + entry_id = ufp.entry.entry_id - light2 = mock_light.copy() - light2._api = mock_entry.api - light2.name = "Test Light 2" - light2.id = "lightid2" - mock_entry.api.bootstrap.lights = { - light1.id: light1, - light2.id: light2, - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + registry: er.EntityRegistry = er.async_get(hass) + entity = registry.async_get(entity_id) + assert entity is not None + device_registry = dr.async_get(hass) - registry = er.async_get(hass) - registry.async_get_or_create( - Platform.BUTTON, DOMAIN, light1.id, config_entry=mock_entry.entry - ) - registry.async_get_or_create( - Platform.BUTTON, - DOMAIN, - f"{light2.id}_reboot", - config_entry=mock_entry.entry, + live_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) + is False ) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "e9:88:e7:b8:b4:40")}, + ) + assert ( + await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) + is True + ) + + +async def test_device_remove_devices_nvr( + hass: HomeAssistant, + ufp: MockUFPFixture, + hass_ws_client: Callable[ + [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] + ], +) -> None: + """Test we can only remove a NVR device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() + entry_id = ufp.entry.entry_id - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + device_registry = dr.async_get(hass) - buttons = [] - for entity in er.async_entries_for_config_entry( - registry, mock_entry.entry.entry_id - ): - if entity.domain == Platform.BUTTON.value: - buttons.append(entity) - print(entity.entity_id) - assert len(buttons) == 2 - - assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") is None - assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid1") - assert light is not None - assert light.unique_id == f"{light1.id}_reboot" - - assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") is None - assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2_reboot") - assert light is not None - assert light.unique_id == f"{light2.id}_reboot" - - -async def test_migrate_reboot_button_no_device( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Test migrating unique ID of reboot button if UniFi Protect device ID changed.""" - - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - light1.id = "lightid1" - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - - registry = er.async_get(hass) - registry.async_get_or_create( - Platform.BUTTON, DOMAIN, "lightid2", config_entry=mock_entry.entry + live_device_entry = list(device_registry.devices.values())[0] + assert ( + await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) + is False ) - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac - - buttons = [] - for entity in er.async_entries_for_config_entry( - registry, mock_entry.entry.entry_id - ): - if entity.domain == Platform.BUTTON.value: - buttons.append(entity) - assert len(buttons) == 2 - - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2") - assert light is not None - assert light.unique_id == "lightid2" - - -async def test_migrate_reboot_button_fail( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Test migrating unique ID of reboot button.""" - - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - light1.id = "lightid1" - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - - registry = er.async_get(hass) - registry.async_get_or_create( - Platform.BUTTON, - DOMAIN, - light1.id, - config_entry=mock_entry.entry, - suggested_object_id=light1.name, - ) - registry.async_get_or_create( - Platform.BUTTON, - DOMAIN, - f"{light1.id}_reboot", - config_entry=mock_entry.entry, - suggested_object_id=light1.name, - ) - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac - - light = registry.async_get(f"{Platform.BUTTON}.test_light_1") - assert light is not None - assert light.unique_id == f"{light1.id}" diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 8f4dc4f8fcf..40f2191828e 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -2,11 +2,10 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import Light +from pyunifiprotect.data.types import LEDLevel from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -20,45 +19,36 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + init_entry, + remove_entities, +) -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Fixture for a single light for testing the light platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy(deep=True) - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.is_light_on = False - - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() +async def test_light_remove(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): + """Test removing and re-adding a light device.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.LIGHT, 0, 0) + await adopt_devices(hass, ufp, [light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - - yield (light_obj, "light.test_light") - - Light.__config__.validate_assignment = True async def test_light_setup( - hass: HomeAssistant, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity setup.""" - unique_id = light[0].id - entity_id = light[1] + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + unique_id = light.mac + entity_id = "light.test_light" entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) @@ -72,41 +62,42 @@ async def test_light_setup( async def test_light_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity update.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_light = light[0].copy() + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + new_light = light.copy() new_light.is_light_on = True - new_light.light_device_settings.led_level = 3 + new_light.light_device_settings.led_level = LEDLevel(3) mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_light - new_bootstrap.lights = {new_light.id: new_light} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.lights = {new_light.id: new_light} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(light[1]) + state = hass.states.get("light.test_light") assert state assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 128 async def test_light_turn_on( - hass: HomeAssistant, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity turn off.""" - entity_id = light[1] - light[0].__fields__["set_light"] = Mock() - light[0].set_light = AsyncMock() + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + entity_id = "light.test_light" + light.__fields__["set_light"] = Mock() + light.set_light = AsyncMock() await hass.services.async_call( "light", @@ -115,18 +106,20 @@ async def test_light_turn_on( blocking=True, ) - light[0].set_light.assert_called_once_with(True, 3) + light.set_light.assert_called_once_with(True, 3) async def test_light_turn_off( - hass: HomeAssistant, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity turn on.""" - entity_id = light[1] - light[0].__fields__["set_light"] = Mock() - light[0].set_light = AsyncMock() + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + entity_id = "light.test_light" + light.__fields__["set_light"] = Mock() + light.set_light = AsyncMock() await hass.services.async_call( "light", @@ -135,4 +128,4 @@ async def test_light_turn_off( blocking=True, ) - light[0].set_light.assert_called_once_with(False) + light.set_light.assert_called_once_with(False) diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 0a02fcb22a4..d6534e93845 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -2,10 +2,8 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import Doorlock, LockStatusType from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -23,45 +21,41 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + init_entry, + remove_entities, +) -@pytest.fixture(name="doorlock") -async def doorlock_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_doorlock: Doorlock +async def test_lock_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock ): - """Fixture for a single doorlock for testing the lock platform.""" - - # disable pydantic validation so mocking can happen - Doorlock.__config__.validate_assignment = False - - lock_obj = mock_doorlock.copy(deep=True) - lock_obj._api = mock_entry.api - lock_obj.name = "Test Lock" - lock_obj.lock_status = LockStatusType.OPEN - - mock_entry.api.bootstrap.doorlocks = { - lock_obj.id: lock_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a lock device.""" + await init_entry(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + await remove_entities(hass, [doorlock]) + assert_entity_counts(hass, Platform.LOCK, 0, 0) + await adopt_devices(hass, ufp, [doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - - yield (lock_obj, "lock.test_lock_lock") - - Doorlock.__config__.validate_assignment = True async def test_lock_setup( hass: HomeAssistant, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity setup.""" - unique_id = f"{doorlock[0].id}_lock" - entity_id = doorlock[1] + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + unique_id = f"{doorlock.mac}_lock" + entity_id = "lock.test_lock_lock" entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) @@ -76,166 +70,183 @@ async def test_lock_setup( async def test_lock_locked( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity locked.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_LOCKED async def test_lock_unlocking( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity unlocking.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.OPENING mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_UNLOCKING async def test_lock_locking( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity locking.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.CLOSING mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_LOCKING async def test_lock_jammed( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity jammed.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.JAMMED_WHILE_CLOSING mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_JAMMED async def test_lock_unavailable( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity unavailable.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.NOT_CALIBRATED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_UNAVAILABLE async def test_lock_do_lock( hass: HomeAssistant, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity lock service.""" - doorlock[0].__fields__["close_lock"] = Mock() - doorlock[0].close_lock = AsyncMock() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + doorlock.__fields__["close_lock"] = Mock() + doorlock.close_lock = AsyncMock() await hass.services.async_call( "lock", "lock", - {ATTR_ENTITY_ID: doorlock[1]}, + {ATTR_ENTITY_ID: "lock.test_lock_lock"}, blocking=True, ) - doorlock[0].close_lock.assert_called_once() + doorlock.close_lock.assert_called_once() async def test_lock_do_unlock( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity unlock service.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() new_lock.__fields__["open_lock"] = Mock() @@ -244,7 +255,7 @@ async def test_lock_do_unlock( await hass.services.async_call( "lock", "unlock", - {ATTR_ENTITY_ID: doorlock[1]}, + {ATTR_ENTITY_ID: "lock.test_lock_lock"}, blocking=True, ) diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index c4586eb7880..ade84e2d51c 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -2,7 +2,6 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock, patch import pytest @@ -26,55 +25,48 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + init_entry, + remove_entities, +) -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +async def test_media_player_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): - """Fixture for a single camera for testing the media_player platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_speaker = True - - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a light device.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + await remove_entities(hass, [doorbell]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 0, 0) + await adopt_devices(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - - yield (camera_obj, "media_player.test_camera_speaker") - - Camera.__config__.validate_assignment = True async def test_media_player_setup( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity setup.""" - unique_id = f"{camera[0].id}_speaker" - entity_id = camera[1] + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + unique_id = f"{doorbell.mac}_speaker" + entity_id = "media_player.test_camera_speaker" entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert entity.unique_id == unique_id - expected_volume = float(camera[0].speaker_settings.volume / 100) + expected_volume = float(doorbell.speaker_settings.volume / 100) state = hass.states.get(entity_id) assert state @@ -87,13 +79,16 @@ async def test_media_player_setup( async def test_media_player_update( hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity update.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + new_camera = doorbell.copy() new_camera.talkback_stream = Mock() new_camera.talkback_stream.is_running = True @@ -101,44 +96,51 @@ async def test_media_player_update( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get("media_player.test_camera_speaker") assert state assert state.state == STATE_PLAYING async def test_media_player_set_volume( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test set_volume_level.""" - camera[0].__fields__["set_speaker_volume"] = Mock() - camera[0].set_speaker_volume = AsyncMock() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["set_speaker_volume"] = Mock() + doorbell.set_speaker_volume = AsyncMock() await hass.services.async_call( "media_player", "volume_set", - {ATTR_ENTITY_ID: camera[1], "volume_level": 0.5}, + {ATTR_ENTITY_ID: "media_player.test_camera_speaker", "volume_level": 0.5}, blocking=True, ) - camera[0].set_speaker_volume.assert_called_once_with(50) + doorbell.set_speaker_volume.assert_called_once_with(50) async def test_media_player_stop( hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test media_stop.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + new_camera = doorbell.copy() new_camera.talkback_stream = AsyncMock() new_camera.talkback_stream.is_running = True @@ -146,15 +148,14 @@ async def test_media_player_stop( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() await hass.services.async_call( "media_player", "media_stop", - {ATTR_ENTITY_ID: camera[1]}, + {ATTR_ENTITY_ID: "media_player.test_camera_speaker"}, blocking=True, ) @@ -163,44 +164,56 @@ async def test_media_player_stop( async def test_media_player_play( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media.""" - camera[0].__fields__["stop_audio"] = Mock() - camera[0].__fields__["play_audio"] = Mock() - camera[0].__fields__["wait_until_audio_completes"] = Mock() - camera[0].stop_audio = AsyncMock() - camera[0].play_audio = AsyncMock() - camera[0].wait_until_audio_completes = AsyncMock() + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["stop_audio"] = Mock() + doorbell.__fields__["play_audio"] = Mock() + doorbell.__fields__["wait_until_audio_completes"] = Mock() + doorbell.stop_audio = AsyncMock() + doorbell.play_audio = AsyncMock() + doorbell.wait_until_audio_completes = AsyncMock() await hass.services.async_call( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "http://example.com/test.mp3", "media_content_type": "music", }, blocking=True, ) - camera[0].play_audio.assert_called_once_with( + doorbell.play_audio.assert_called_once_with( "http://example.com/test.mp3", blocking=False ) - camera[0].wait_until_audio_completes.assert_called_once() + doorbell.wait_until_audio_completes.assert_called_once() async def test_media_player_play_media_source( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media.""" - camera[0].__fields__["stop_audio"] = Mock() - camera[0].__fields__["play_audio"] = Mock() - camera[0].__fields__["wait_until_audio_completes"] = Mock() - camera[0].stop_audio = AsyncMock() - camera[0].play_audio = AsyncMock() - camera[0].wait_until_audio_completes = AsyncMock() + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["stop_audio"] = Mock() + doorbell.__fields__["play_audio"] = Mock() + doorbell.__fields__["wait_until_audio_completes"] = Mock() + doorbell.stop_audio = AsyncMock() + doorbell.play_audio = AsyncMock() + doorbell.wait_until_audio_completes = AsyncMock() with patch( "homeassistant.components.media_source.async_resolve_media", @@ -210,65 +223,75 @@ async def test_media_player_play_media_source( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "media-source://some_source/some_id", "media_content_type": "audio/mpeg", }, blocking=True, ) - camera[0].play_audio.assert_called_once_with( + doorbell.play_audio.assert_called_once_with( "http://example.com/test.mp3", blocking=False ) - camera[0].wait_until_audio_completes.assert_called_once() + doorbell.wait_until_audio_completes.assert_called_once() async def test_media_player_play_invalid( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media, not music.""" - camera[0].__fields__["play_audio"] = Mock() - camera[0].play_audio = AsyncMock() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["play_audio"] = Mock() + doorbell.play_audio = AsyncMock() with pytest.raises(HomeAssistantError): await hass.services.async_call( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "/test.png", "media_content_type": "image", }, blocking=True, ) - assert not camera[0].play_audio.called + assert not doorbell.play_audio.called async def test_media_player_play_error( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media, not music.""" - camera[0].__fields__["play_audio"] = Mock() - camera[0].__fields__["wait_until_audio_completes"] = Mock() - camera[0].play_audio = AsyncMock(side_effect=StreamError) - camera[0].wait_until_audio_completes = AsyncMock() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["play_audio"] = Mock() + doorbell.__fields__["wait_until_audio_completes"] = Mock() + doorbell.play_audio = AsyncMock(side_effect=StreamError) + doorbell.wait_until_audio_completes = AsyncMock() with pytest.raises(HomeAssistantError): await hass.services.async_call( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "/test.mp3", "media_content_type": "music", }, blocking=True, ) - assert camera[0].play_audio.called - assert not camera[0].wait_until_audio_completes.called + assert doorbell.play_audio.called + assert not doorbell.wait_until_audio_completes.called diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py new file mode 100644 index 00000000000..64c8384d400 --- /dev/null +++ b/tests/components/unifiprotect/test_migrate.py @@ -0,0 +1,235 @@ +"""Test the UniFi Protect setup flow.""" +# pylint: disable=protected-access +from __future__ import annotations + +from unittest.mock import AsyncMock + +from pyunifiprotect.data import Light +from pyunifiprotect.exceptions import NvrError + +from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .utils import ( + MockUFPFixture, + generate_random_ids, + init_entry, + regenerate_device_ids, +) + + +async def test_migrate_reboot_button( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test migrating unique ID of reboot button.""" + + light1 = light.copy() + light1.name = "Test Light 1" + regenerate_device_ids(light1) + + light2 = light.copy() + light2.name = "Test Light 2" + regenerate_device_ids(light2) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, DOMAIN, light1.id, config_entry=ufp.entry + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light2.mac}_reboot", + config_entry=ufp.entry, + ) + + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light1, light2], regenerate_ids=False) + + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac + + buttons = [] + for entity in er.async_entries_for_config_entry(registry, ufp.entry.entry_id): + if entity.domain == Platform.BUTTON.value: + buttons.append(entity) + assert len(buttons) == 2 + + assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") is None + assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light1.id.lower()}") + assert light is not None + assert light.unique_id == f"{light1.mac}_reboot" + + assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") is None + assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None + light = registry.async_get( + f"{Platform.BUTTON}.unifiprotect_{light2.mac.lower()}_reboot" + ) + assert light is not None + assert light.unique_id == f"{light2.mac}_reboot" + + +async def test_migrate_nvr_mac(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): + """Test migrating unique ID of NVR to use MAC address.""" + + light1 = light.copy() + light1.name = "Test Light 1" + regenerate_device_ids(light1) + + light2 = light.copy() + light2.name = "Test Light 2" + regenerate_device_ids(light2) + + nvr = ufp.api.bootstrap.nvr + regenerate_device_ids(nvr) + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + f"{nvr.id}_storage_utilization", + config_entry=ufp.entry, + ) + + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light1, light2], regenerate_ids=False) + + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac + + assert registry.async_get(f"{Platform.SENSOR}.{DOMAIN}_storage_utilization") is None + assert ( + registry.async_get(f"{Platform.SENSOR}.{DOMAIN}_storage_utilization_2") is None + ) + sensor = registry.async_get( + f"{Platform.SENSOR}.{DOMAIN}_{nvr.id}_storage_utilization" + ) + assert sensor is not None + assert sensor.unique_id == f"{nvr.mac}_storage_utilization" + + +async def test_migrate_reboot_button_no_device( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test migrating unique ID of reboot button if UniFi Protect device ID changed.""" + + light2_id, _ = generate_random_ids() + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, DOMAIN, light2_id, config_entry=ufp.entry + ) + + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light], regenerate_ids=False) + + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac + + buttons = [] + for entity in er.async_entries_for_config_entry(registry, ufp.entry.entry_id): + if entity.domain == Platform.BUTTON.value: + buttons.append(entity) + assert len(buttons) == 2 + + entity = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light2_id.lower()}") + assert entity is not None + assert entity.unique_id == light2_id + + +async def test_migrate_reboot_button_fail( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test migrating unique ID of reboot button.""" + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + light.id, + config_entry=ufp.entry, + suggested_object_id=light.display_name, + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light.id}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.display_name, + ) + + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light], regenerate_ids=False) + + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac + + entity = registry.async_get(f"{Platform.BUTTON}.test_light") + assert entity is not None + assert entity.unique_id == f"{light.mac}" + + +async def test_migrate_device_mac_button_fail( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test migrating unique ID to MAC format.""" + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light.id}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.display_name, + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light.mac}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.display_name, + ) + + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light], regenerate_ids=False) + + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac + + entity = registry.async_get(f"{Platform.BUTTON}.test_light") + assert entity is not None + assert entity.unique_id == f"{light.id}_reboot" + + +async def test_migrate_device_mac_bootstrap_fail( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test migrating with a network error.""" + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light.id}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.name, + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light.mac}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.name, + ) + + ufp.api.get_bootstrap = AsyncMock(side_effect=NvrError) + await init_entry(hass, ufp, [light], regenerate_ids=False) + + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index f516ad64a0b..51e9dfc85a2 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -19,118 +19,64 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, + adopt_devices, assert_entity_counts, ids_from_device_description, + init_entry, + remove_entities, ) -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +async def test_number_sensor_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera, unadopted_camera: Camera ): - """Fixture for a single light for testing the number platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy(deep=True) - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.light_device_settings.pir_sensitivity = 45 - light_obj.light_device_settings.pir_duration = timedelta(seconds=45) - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.NUMBER, 2, 2) - - yield light_obj - - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the number platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.can_optical_zoom = True - camera_obj.feature_flags.has_mic = True - # has_wdr is an the inverse of has HDR - camera_obj.feature_flags.has_hdr = False - camera_obj.isp_settings.wdr = 0 - camera_obj.mic_volume = 0 - camera_obj.isp_settings.zoom_position = 0 - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a camera device.""" + await init_entry(hass, ufp, [camera, unadopted_camera]) + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + await remove_entities(hass, [camera, unadopted_camera]) + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + await adopt_devices(hass, ufp, [camera, unadopted_camera]) assert_entity_counts(hass, Platform.NUMBER, 3, 3) - yield camera_obj - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="doorlock") -async def doorlock_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_doorlock: Doorlock +async def test_number_sensor_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): - """Fixture for a single doorlock for testing the number platform.""" + """Test removing and re-adding a light device.""" - # disable pydantic validation so mocking can happen - Doorlock.__config__.validate_assignment = False + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + await adopt_devices(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) - lock_obj = mock_doorlock.copy(deep=True) - lock_obj._api = mock_entry.api - lock_obj.name = "Test Lock" - lock_obj.auto_close_time = timedelta(seconds=45) - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.doorlocks = { - lock_obj.id: lock_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() +async def test_number_lock_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock +): + """Test removing and re-adding a light device.""" + await init_entry(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.NUMBER, 1, 1) + await remove_entities(hass, [doorlock]) + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + await adopt_devices(hass, ufp, [doorlock]) assert_entity_counts(hass, Platform.NUMBER, 1, 1) - - yield lock_obj - - Doorlock.__config__.validate_assignment = True async def test_number_setup_light( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test number entity setup for light devices.""" - entity_registry = er.async_get(hass) + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + entity_registry = er.async_get(hass) for description in LIGHT_NUMBERS: unique_id, entity_id = ids_from_device_description( Platform.NUMBER, light, description @@ -147,11 +93,13 @@ async def test_number_setup_light( async def test_number_setup_camera_all( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test number entity setup for camera devices (all features).""" + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + entity_registry = er.async_get(hass) for description in CAMERA_NUMBERS: @@ -170,64 +118,38 @@ async def test_number_setup_camera_all( async def test_number_setup_camera_none( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test number entity setup for camera devices (no features).""" - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.can_optical_zoom = False - camera_obj.feature_flags.has_mic = False + camera.feature_flags.can_optical_zoom = False + camera.feature_flags.has_mic = False # has_wdr is an the inverse of has HDR - camera_obj.feature_flags.has_hdr = True - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + camera.feature_flags.has_hdr = True + await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.NUMBER, 0, 0) async def test_number_setup_camera_missing_attr( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test number entity setup for camera devices (no features, bad attrs).""" - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags = None - - Camera.__config__.validate_assignment = True - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + camera.feature_flags = None + await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.NUMBER, 0, 0) -async def test_number_light_sensitivity(hass: HomeAssistant, light: Light): +async def test_number_light_sensitivity( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): """Test sensitivity number entity for lights.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + description = LIGHT_NUMBERS[0] assert description.ufp_set_method is not None @@ -243,9 +165,14 @@ async def test_number_light_sensitivity(hass: HomeAssistant, light: Light): light.set_sensitivity.assert_called_once_with(15.0) -async def test_number_light_duration(hass: HomeAssistant, light: Light): +async def test_number_light_duration( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): """Test auto-shutoff duration number entity for lights.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + description = LIGHT_NUMBERS[1] light.__fields__["set_duration"] = Mock() @@ -262,10 +189,16 @@ async def test_number_light_duration(hass: HomeAssistant, light: Light): @pytest.mark.parametrize("description", CAMERA_NUMBERS) async def test_number_camera_simple( - hass: HomeAssistant, camera: Camera, description: ProtectNumberEntityDescription + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: Camera, + description: ProtectNumberEntityDescription, ): """Tests all simple numbers for cameras.""" + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + assert description.ufp_set_method is not None camera.__fields__[description.ufp_set_method] = Mock() @@ -281,9 +214,14 @@ async def test_number_camera_simple( set_method.assert_called_once_with(1.0) -async def test_number_lock_auto_close(hass: HomeAssistant, doorlock: Doorlock): +async def test_number_lock_auto_close( + hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock +): """Test auto-lock timeout for locks.""" + await init_entry(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.NUMBER, 1, 1) + description = DOORLOCK_NUMBERS[0] doorlock.__fields__["set_auto_close_time"] = Mock() diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index fc0abbe29ca..46bc70f61f6 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -3,7 +3,7 @@ from __future__ import annotations from copy import copy -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import AsyncMock, Mock, patch import pytest @@ -38,161 +38,68 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, P from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, + adopt_devices, assert_entity_counts, ids_from_device_description, + init_entry, + remove_entities, ) -@pytest.fixture(name="viewer") -async def viewer_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_viewer: Viewer, - mock_liveview: Liveview, +async def test_select_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera ): - """Fixture for a single viewport for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Viewer.__config__.validate_assignment = False - - viewer_obj = mock_viewer.copy(deep=True) - viewer_obj._api = mock_entry.api - viewer_obj.name = "Test Viewer" - viewer_obj.liveview_id = mock_liveview.id - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.viewers = { - viewer_obj.id: viewer_obj, - } - mock_entry.api.bootstrap.liveviews = {mock_liveview.id: mock_liveview} - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SELECT, 1, 1) - - yield viewer_obj - - Viewer.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_lcd_screen = True - camera_obj.feature_flags.has_chime = True - camera_obj.recording_settings.mode = RecordingMode.ALWAYS - camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO - camera_obj.lcd_message = None - camera_obj.chime_duration = 0 - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a camera device.""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SELECT, 0, 0) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.SELECT, 4, 4) - yield camera_obj - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_light: Light, - camera: Camera, +async def test_select_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): - """Fixture for a single light for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy(deep=True) - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.camera_id = None - light_obj.light_mode_settings.mode = LightModeType.MOTION - light_obj.light_mode_settings.enable_at = LightModeEnableType.DARK - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = {camera.id: camera} - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_reload(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SELECT, 6, 6) - - yield light_obj - - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_none") -async def camera_none_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_lcd_screen = False - camera_obj.feature_flags.has_chime = False - camera_obj.recording_settings.mode = RecordingMode.ALWAYS - camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + """Test removing and re-adding a light device.""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.SELECT, 0, 0) + await adopt_devices(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) - yield camera_obj - Camera.__config__.validate_assignment = True +async def test_select_viewer_remove( + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer +): + """Test removing and re-adding a light device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + await remove_entities(hass, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 0, 0) + await adopt_devices(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) async def test_select_setup_light( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test select entity setup for light devices.""" + light.light_mode_settings.enable_at = LightModeEnableType.DARK + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + entity_registry = er.async_get(hass) expected_values = ("On Motion - When Dark", "Not Paired") @@ -212,11 +119,14 @@ async def test_select_setup_light( async def test_select_setup_viewer( - hass: HomeAssistant, - viewer: Viewer, + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer, liveview: Liveview ): """Test select entity setup for light devices.""" + ufp.api.bootstrap.liveviews = {liveview.id: liveview} + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + entity_registry = er.async_get(hass) description = VIEWER_SELECTS[0] @@ -235,15 +145,46 @@ async def test_select_setup_viewer( async def test_select_setup_camera_all( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test select entity setup for camera devices (all features).""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + entity_registry = er.async_get(hass) expected_values = ("Always", "Auto", "Default Message (Welcome)", "None") for index, description in enumerate(CAMERA_SELECTS): + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, doorbell, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_setup_camera_none( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +): + """Test select entity setup for camera devices (no features).""" + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + + entity_registry = er.async_get(hass) + expected_values = ("Always", "Auto", "Default Message (Welcome)") + + for index, description in enumerate(CAMERA_SELECTS): + if index == 2: + return + unique_id, entity_id = ids_from_device_description( Platform.SELECT, camera, description ) @@ -258,41 +199,15 @@ async def test_select_setup_camera_all( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_select_setup_camera_none( - hass: HomeAssistant, - camera_none: Camera, -): - """Test select entity setup for camera devices (no features).""" - - entity_registry = er.async_get(hass) - expected_values = ("Always", "Auto", "Default Message (Welcome)") - - for index, description in enumerate(CAMERA_SELECTS): - if index == 2: - return - - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, camera_none, description - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.unique_id == unique_id - - state = hass.states.get(entity_id) - assert state - assert state.state == expected_values[index] - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - async def test_select_update_liveview( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - viewer: Viewer, - mock_liveview: Liveview, + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer, liveview: Liveview ): """Test select entity update (new Liveview).""" + ufp.api.bootstrap.liveviews = {liveview.id: liveview} + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + _, entity_id = ids_from_device_description( Platform.SELECT, viewer, VIEWER_SELECTS[0] ) @@ -301,17 +216,18 @@ async def test_select_update_liveview( assert state expected_options = state.attributes[ATTR_OPTIONS] - new_bootstrap = copy(mock_entry.api.bootstrap) - new_liveview = copy(mock_liveview) + new_liveview = copy(liveview) new_liveview.id = "test_id" mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_liveview - new_bootstrap.liveviews = {**new_bootstrap.liveviews, new_liveview.id: new_liveview} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.liveviews = { + **ufp.api.bootstrap.liveviews, + new_liveview.id: new_liveview, + } + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -320,16 +236,17 @@ async def test_select_update_liveview( async def test_select_update_doorbell_settings( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test select entity update (new Doorbell Message).""" - expected_length = ( - len(mock_entry.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 - ) + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + + expected_length = len(ufp.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -337,7 +254,7 @@ async def test_select_update_doorbell_settings( assert len(state.attributes[ATTR_OPTIONS]) == expected_length expected_length += 1 - new_nvr = copy(mock_entry.api.bootstrap.nvr) + new_nvr = copy(ufp.api.bootstrap.nvr) new_nvr.__fields__["update_all_messages"] = Mock() new_nvr.update_all_messages = Mock() @@ -353,8 +270,8 @@ async def test_select_update_doorbell_settings( mock_msg.changed_data = {"doorbell_settings": {}} mock_msg.new_obj = new_nvr - mock_entry.api.bootstrap.nvr = new_nvr - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.nvr = new_nvr + ufp.ws_msg(mock_msg) await hass.async_block_till_done() new_nvr.update_all_messages.assert_called_once() @@ -365,22 +282,22 @@ async def test_select_update_doorbell_settings( async def test_select_update_doorbell_message( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test select entity update (change doorbell message).""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) assert state assert state.state == "Default Message (Welcome)" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera.copy() + new_camera = doorbell.copy() new_camera.lcd_message = LCDMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text="Test" ) @@ -389,9 +306,8 @@ async def test_select_update_doorbell_message( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -400,10 +316,13 @@ async def test_select_update_doorbell_message( async def test_select_set_option_light_motion( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test Light Mode select.""" + + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) light.__fields__["set_light_settings"] = Mock() @@ -422,10 +341,13 @@ async def test_select_set_option_light_motion( async def test_select_set_option_light_camera( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, camera: Camera ): """Test Paired Camera select.""" + + await init_entry(hass, ufp, [light, camera]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) light.__fields__["set_paired_camera"] = Mock() @@ -453,16 +375,19 @@ async def test_select_set_option_light_camera( async def test_select_set_option_camera_recording( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Recording Mode select.""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[0] + Platform.SELECT, doorbell, CAMERA_SELECTS[0] ) - camera.__fields__["set_recording_mode"] = Mock() - camera.set_recording_mode = AsyncMock() + doorbell.__fields__["set_recording_mode"] = Mock() + doorbell.set_recording_mode = AsyncMock() await hass.services.async_call( "select", @@ -471,20 +396,23 @@ async def test_select_set_option_camera_recording( blocking=True, ) - camera.set_recording_mode.assert_called_once_with(RecordingMode.NEVER) + doorbell.set_recording_mode.assert_called_once_with(RecordingMode.NEVER) async def test_select_set_option_camera_ir( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Infrared Mode select.""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[1] + Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) - camera.__fields__["set_ir_led_model"] = Mock() - camera.set_ir_led_model = AsyncMock() + doorbell.__fields__["set_ir_led_model"] = Mock() + doorbell.set_ir_led_model = AsyncMock() await hass.services.async_call( "select", @@ -493,20 +421,23 @@ async def test_select_set_option_camera_ir( blocking=True, ) - camera.set_ir_led_model.assert_called_once_with(IRLEDMode.ON) + doorbell.set_ir_led_model.assert_called_once_with(IRLEDMode.ON) async def test_select_set_option_camera_doorbell_custom( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text select (user defined message).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "select", @@ -515,22 +446,25 @@ async def test_select_set_option_camera_doorbell_custom( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.CUSTOM_MESSAGE, text="Test" ) async def test_select_set_option_camera_doorbell_unifi( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text select (unifi message).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "select", @@ -542,7 +476,7 @@ async def test_select_set_option_camera_doorbell_unifi( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR ) @@ -556,20 +490,23 @@ async def test_select_set_option_camera_doorbell_unifi( blocking=True, ) - camera.set_lcd_text.assert_called_with(None) + doorbell.set_lcd_text.assert_called_with(None) async def test_select_set_option_camera_doorbell_default( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text select (default message).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "select", @@ -581,14 +518,18 @@ async def test_select_set_option_camera_doorbell_default( blocking=True, ) - camera.set_lcd_text.assert_called_once_with(None) + doorbell.set_lcd_text.assert_called_once_with(None) async def test_select_set_option_viewer( - hass: HomeAssistant, - viewer: Viewer, + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer, liveview: Liveview ): """Test Liveview select.""" + + ufp.api.bootstrap.liveviews = {liveview.id: liveview} + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + _, entity_id = ids_from_device_description( Platform.SELECT, viewer, VIEWER_SELECTS[0] ) @@ -609,16 +550,19 @@ async def test_select_set_option_viewer( async def test_select_service_doorbell_invalid( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text service (invalid).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[1] + Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -628,20 +572,23 @@ async def test_select_service_doorbell_invalid( blocking=True, ) - assert not camera.set_lcd_text.called + assert not doorbell.set_lcd_text.called async def test_select_service_doorbell_success( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text service (success).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "unifiprotect", @@ -653,7 +600,7 @@ async def test_select_service_doorbell_success( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.CUSTOM_MESSAGE, "Test", reset_at=None ) @@ -662,18 +609,23 @@ async def test_select_service_doorbell_success( async def test_select_service_doorbell_with_reset( mock_now, hass: HomeAssistant, - camera: Camera, + ufp: MockUFPFixture, + doorbell: Camera, + fixed_now: datetime, ): """Test Doorbell Text service (success with reset time).""" - now = utcnow() - mock_now.return_value = now + + mock_now.return_value = fixed_now _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "unifiprotect", @@ -686,8 +638,8 @@ async def test_select_service_doorbell_with_reset( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.CUSTOM_MESSAGE, "Test", - reset_at=now + timedelta(minutes=60), + reset_at=fixed_now + timedelta(minutes=60), ) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index dff746c167f..fcad6ce2725 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -2,11 +2,9 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from datetime import datetime, timedelta from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import ( NVR, Camera, @@ -15,7 +13,6 @@ from pyunifiprotect.data import ( Sensor, SmartDetectObjectType, ) -from pyunifiprotect.data.base import WifiConnectionState, WiredConnectionState from pyunifiprotect.data.nvr import EventMetadata from homeassistant.components.unifiprotect.const import ( @@ -42,145 +39,56 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, + adopt_devices, assert_entity_counts, enable_entity, ids_from_device_description, + init_entry, + remove_entities, + reset_objects, time_changed, ) +CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] +SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] -@pytest.fixture(name="sensor") -async def sensor_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, + +async def test_sensor_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera ): - """Fixture for a single sensor for testing the sensor platform.""" + """Test removing and re-adding a camera device.""" - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy(deep=True) - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.battery_status.percentage = 10.0 - sensor_obj.light_settings.is_enabled = True - sensor_obj.humidity_settings.is_enabled = True - sensor_obj.temperature_settings.is_enabled = True - sensor_obj.alarm_settings.is_enabled = True - sensor_obj.stats.light.value = 10.0 - sensor_obj.stats.humidity.value = 10.0 - sensor_obj.stats.temperature.value = 10.0 - sensor_obj.up_since = now - sensor_obj.bluetooth_connection_state.signal_strength = -50.0 - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - yield sensor_obj - - Sensor.__config__.validate_assignment = True + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SENSOR, 12, 9) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) -@pytest.fixture(name="sensor_none") -async def sensor_none_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, +async def test_sensor_sensor_remove( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): - """Fixture for a single sensor for testing the sensor platform.""" + """Test removing and re-adding a light device.""" - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy(deep=True) - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.battery_status.percentage = 10.0 - sensor_obj.light_settings.is_enabled = False - sensor_obj.humidity_settings.is_enabled = False - sensor_obj.temperature_settings.is_enabled = False - sensor_obj.alarm_settings.is_enabled = False - sensor_obj.up_since = now - sensor_obj.bluetooth_connection_state.signal_strength = -50.0 - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - # 4 from all, 5 from sense, 12 NVR + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 22, 14) + await remove_entities(hass, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 12, 9) + await adopt_devices(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) - - yield sensor_obj - - Sensor.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_camera: Camera, - now: datetime, -): - """Fixture for a single camera for testing the sensor platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_smart_detect = True - camera_obj.feature_flags.has_chime = True - camera_obj.is_smart_detected = False - camera_obj.wired_connection_state = WiredConnectionState(phy_rate=1000) - camera_obj.wifi_connection_state = WifiConnectionState( - signal_quality=100, signal_strength=-50 - ) - camera_obj.stats.rx_bytes = 100.0 - camera_obj.stats.tx_bytes = 100.0 - camera_obj.stats.video.recording_start = now - camera_obj.stats.storage.used = 100.0 - camera_obj.stats.storage.used = 100.0 - camera_obj.stats.storage.rate = 100.0 - camera_obj.voltage = 20.0 - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - yield camera_obj - - Camera.__config__.validate_assignment = True async def test_sensor_setup_sensor( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test sensor entity setup for sensor devices.""" - # 5 from all, 5 from sense, 12 NVR + + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) entity_registry = er.async_get(hass) @@ -192,7 +100,58 @@ async def test_sensor_setup_sensor( "10.0", "none", ) - for index, description in enumerate(SENSE_SENSORS): + for index, description in enumerate(SENSE_SENSORS_WRITE): + if not description.entity_registry_enabled_default: + continue + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor_all, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # BLE signal + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor_all, ALL_DEVICES_SENSORS[1] + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == "-50" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_sensor_setup_sensor_none( + hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor +): + """Test sensor entity setup for sensor devices with no sensors enabled.""" + + await init_entry(hass, ufp, [sensor]) + assert_entity_counts(hass, Platform.SENSOR, 22, 14) + + entity_registry = er.async_get(hass) + + expected_values = ( + "10", + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + ) + for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue unique_id, entity_id = ids_from_device_description( @@ -208,63 +167,15 @@ async def test_sensor_setup_sensor( assert state.state == expected_values[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # BLE signal - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, ALL_DEVICES_SENSORS[1] - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.disabled is True - assert entity.unique_id == unique_id - - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - - state = hass.states.get(entity_id) - assert state - assert state.state == "-50" - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - -async def test_sensor_setup_sensor_none( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor_none: Sensor -): - """Test sensor entity setup for sensor devices with no sensors enabled.""" - - entity_registry = er.async_get(hass) - - expected_values = ( - "10", - STATE_UNAVAILABLE, - STATE_UNAVAILABLE, - STATE_UNAVAILABLE, - STATE_UNAVAILABLE, - ) - for index, description in enumerate(SENSE_SENSORS): - if not description.entity_registry_enabled_default: - continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_none, description - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.unique_id == unique_id - - state = hass.states.get(entity_id) - assert state - assert state.state == expected_values[index] - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - async def test_sensor_setup_nvr( - hass: HomeAssistant, mock_entry: MockEntityFixture, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, fixed_now: datetime ): """Test sensor entity setup for NVR device.""" - mock_entry.api.bootstrap.reset_objects() - nvr: NVR = mock_entry.api.bootstrap.nvr - nvr.up_since = now + reset_objects(ufp.api.bootstrap) + nvr: NVR = ufp.api.bootstrap.nvr + nvr.up_since = fixed_now nvr.system_info.cpu.average_load = 50.0 nvr.system_info.cpu.temperature = 50.0 nvr.storage_stats.utilization = 50.0 @@ -278,16 +189,15 @@ async def test_sensor_setup_nvr( nvr.storage_stats.storage_distribution.free.percentage = 50.0 nvr.storage_stats.capacity = 50.0 - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - # 2 from all, 4 from sense, 12 NVR assert_entity_counts(hass, Platform.SENSOR, 12, 9) entity_registry = er.async_get(hass) expected_values = ( - now.replace(second=0, microsecond=0).isoformat(), + fixed_now.replace(second=0, microsecond=0).isoformat(), "50.0", "50.0", "50.0", @@ -308,7 +218,7 @@ async def test_sensor_setup_nvr( assert entity.unique_id == unique_id if not description.entity_registry_enabled_default: - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -326,7 +236,7 @@ async def test_sensor_setup_nvr( assert entity.disabled is not description.entity_registry_enabled_default assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -334,22 +244,19 @@ async def test_sensor_setup_nvr( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_sensor_nvr_missing_values( - hass: HomeAssistant, mock_entry: MockEntityFixture, now: datetime -): +async def test_sensor_nvr_missing_values(hass: HomeAssistant, ufp: MockUFPFixture): """Test NVR sensor sensors if no data available.""" - mock_entry.api.bootstrap.reset_objects() - nvr: NVR = mock_entry.api.bootstrap.nvr + reset_objects(ufp.api.bootstrap) + nvr: NVR = ufp.api.bootstrap.nvr nvr.system_info.memory.available = None nvr.system_info.memory.total = None nvr.up_since = None nvr.storage_stats.capacity = None - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - # 2 from all, 4 from sense, 12 NVR assert_entity_counts(hass, Platform.SENSOR, 12, 9) entity_registry = er.async_get(hass) @@ -364,7 +271,7 @@ async def test_sensor_nvr_missing_values( assert entity assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -397,7 +304,7 @@ async def test_sensor_nvr_missing_values( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -406,25 +313,26 @@ async def test_sensor_nvr_missing_values( async def test_sensor_setup_camera( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime ): """Test sensor entity setup for camera devices.""" - # 3 from all, 7 from camera, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 24, 13) + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) entity_registry = er.async_get(hass) expected_values = ( - now.replace(microsecond=0).isoformat(), + fixed_now.replace(microsecond=0).isoformat(), "100", "100.0", "20.0", ) - for index, description in enumerate(CAMERA_SENSORS): + for index, description in enumerate(CAMERA_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, description + Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -440,7 +348,7 @@ async def test_sensor_setup_camera( expected_values = ("100", "100") for index, description in enumerate(CAMERA_DISABLED_SENSORS): unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, description + Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -448,7 +356,7 @@ async def test_sensor_setup_camera( assert entity.disabled is not description.entity_registry_enabled_default assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -457,7 +365,7 @@ async def test_sensor_setup_camera( # Wired signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, ALL_DEVICES_SENSORS[2] + Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[2] ) entity = entity_registry.async_get(entity_id) @@ -465,7 +373,7 @@ async def test_sensor_setup_camera( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -474,7 +382,7 @@ async def test_sensor_setup_camera( # WiFi signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, ALL_DEVICES_SENSORS[3] + Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[3] ) entity = entity_registry.async_get(entity_id) @@ -482,7 +390,7 @@ async def test_sensor_setup_camera( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -491,7 +399,7 @@ async def test_sensor_setup_camera( # Detected Object unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, MOTION_SENSORS[0] + Platform.SENSOR, doorbell, MOTION_SENSORS[0] ) entity = entity_registry.async_get(entity_id) @@ -508,16 +416,20 @@ async def test_sensor_setup_camera( async def test_sensor_setup_camera_with_last_trip_time( hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock, - mock_entry: MockEntityFixture, - camera: Camera, - now: datetime, + ufp: MockUFPFixture, + doorbell: Camera, + fixed_now: datetime, ): """Test sensor entity setup for camera devices with last trip time.""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SENSOR, 25, 25) + entity_registry = er.async_get(hass) # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, MOTION_TRIP_SENSORS[0] + Platform.SENSOR, doorbell, MOTION_TRIP_SENSORS[0] ) entity = entity_registry.async_get(entity_id) @@ -526,35 +438,38 @@ async def test_sensor_setup_camera_with_last_trip_time( state = hass.states.get(entity_id) assert state - assert state.state == "2021-12-20T17:26:53+00:00" + assert ( + state.state + == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + ) assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION async def test_sensor_update_motion( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime ): """Test sensor motion entity.""" - # 3 from all, 7 from camera, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 24, 13) + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, MOTION_SENSORS[0] + Platform.SENSOR, doorbell, MOTION_SENSORS[0] ) event = Event( id="test_event_id", type=EventType.SMART_DETECT, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], - camera_id=camera.id, - api=mock_entry.api, + camera_id=doorbell.id, + api=ufp.api, ) - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera.copy() + new_camera = doorbell.copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_id = event.id @@ -562,10 +477,9 @@ async def test_sensor_update_motion( mock_msg.changed_data = {} mock_msg.new_obj = event - new_bootstrap.cameras = {new_camera.id: new_camera} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -576,31 +490,31 @@ async def test_sensor_update_motion( async def test_sensor_update_alarm( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime ): """Test sensor motion entity.""" - # 5 from all, 5 from sense, 12 NVR + + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, SENSE_SENSORS[4] + Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[4] ) - event_metadata = EventMetadata(sensor_id=sensor.id, alarm_type="smoke") + event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") event = Event( id="test_event_id", type=EventType.SENSOR_ALARM, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[], smart_detect_event_ids=[], metadata=event_metadata, - api=mock_entry.api, + api=ufp.api, ) - new_bootstrap = copy(mock_entry.api.bootstrap) - new_sensor = sensor.copy() + new_sensor = sensor_all.copy() new_sensor.set_alarm_timeout() new_sensor.last_alarm_event_id = event.id @@ -608,10 +522,9 @@ async def test_sensor_update_alarm( mock_msg.changed_data = {} mock_msg.new_obj = event - new_bootstrap.sensors = {new_sensor.id: new_sensor} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.sensors = {new_sensor.id: new_sensor} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -623,15 +536,18 @@ async def test_sensor_update_alarm( async def test_sensor_update_alarm_with_last_trip_time( hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock, - mock_entry: MockEntityFixture, - sensor: Sensor, - now: datetime, + ufp: MockUFPFixture, + sensor_all: Sensor, + fixed_now: datetime, ): """Test sensor motion entity with last trip time.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 22, 22) + # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, SENSE_SENSORS[-3] + Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[-3] ) entity_registry = er.async_get(hass) @@ -641,5 +557,8 @@ async def test_sensor_update_alarm_with_last_trip_time( state = hass.states.get(entity_id) assert state - assert state.state == "2022-01-04T04:03:56+00:00" + assert ( + state.state + == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + ) assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 22f7fdd1a6c..460ba488cb2 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -5,8 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Light, ModelType -from pyunifiprotect.data.devices import Chime +from pyunifiprotect.data import Camera, Chime, Light, ModelType from pyunifiprotect.exceptions import BadRequest from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN @@ -21,15 +20,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import MockEntityFixture +from .utils import MockUFPFixture, init_entry @pytest.fixture(name="device") -async def device_fixture(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def device_fixture(hass: HomeAssistant, ufp: MockUFPFixture): """Fixture with entry setup to call services with.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, []) device_registry = dr.async_get(hass) @@ -37,30 +35,20 @@ async def device_fixture(hass: HomeAssistant, mock_entry: MockEntityFixture): @pytest.fixture(name="subdevice") -async def subdevice_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): +async def subdevice_fixture(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): """Fixture with entry setup to call services with.""" - mock_light._api = mock_entry.api - mock_entry.api.bootstrap.lights = { - mock_light.id: mock_light, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, [light]) device_registry = dr.async_get(hass) return [d for d in device_registry.devices.values() if d.name != "UnifiProtect"][0] -async def test_global_service_bad_device( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture -): +async def test_global_service_bad_device(hass: HomeAssistant, ufp: MockUFPFixture): """Test global service, invalid device ID.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["add_custom_doorbell_message"] = Mock() nvr.add_custom_doorbell_message = AsyncMock() @@ -75,11 +63,11 @@ async def test_global_service_bad_device( async def test_global_service_exception( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture ): """Test global service, unexpected error.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["add_custom_doorbell_message"] = Mock() nvr.add_custom_doorbell_message = AsyncMock(side_effect=BadRequest) @@ -94,11 +82,11 @@ async def test_global_service_exception( async def test_add_doorbell_text( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture ): """Test add_doorbell_text service.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["add_custom_doorbell_message"] = Mock() nvr.add_custom_doorbell_message = AsyncMock() @@ -112,11 +100,11 @@ async def test_add_doorbell_text( async def test_remove_doorbell_text( - hass: HomeAssistant, subdevice: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, subdevice: dr.DeviceEntry, ufp: MockUFPFixture ): """Test remove_doorbell_text service.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["remove_custom_doorbell_message"] = Mock() nvr.remove_custom_doorbell_message = AsyncMock() @@ -130,11 +118,11 @@ async def test_remove_doorbell_text( async def test_set_default_doorbell_text( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture ): """Test set_default_doorbell_text service.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["set_default_doorbell_message"] = Mock() nvr.set_default_doorbell_message = AsyncMock() @@ -149,46 +137,21 @@ async def test_set_default_doorbell_text( async def test_set_chime_paired_doorbells( hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_chime: Chime, - mock_camera: Camera, + ufp: MockUFPFixture, + chime: Chime, + doorbell: Camera, ): """Test set_chime_paired_doorbells.""" - mock_entry.api.update_device = AsyncMock() + ufp.api.update_device = AsyncMock() - mock_chime._api = mock_entry.api - mock_chime.name = "Test Chime" - mock_chime._initial_data = mock_chime.dict() - mock_entry.api.bootstrap.chimes = { - mock_chime.id: mock_chime, - } - - camera1 = mock_camera.copy() - camera1.id = "cameraid1" + camera1 = doorbell.copy() camera1.name = "Test Camera 1" - camera1._api = mock_entry.api - camera1.channels[0]._api = mock_entry.api - camera1.channels[1]._api = mock_entry.api - camera1.channels[2]._api = mock_entry.api - camera1.feature_flags.has_chime = True - camera2 = mock_camera.copy() - camera2.id = "cameraid2" + camera2 = doorbell.copy() camera2.name = "Test Camera 2" - camera2._api = mock_entry.api - camera2.channels[0]._api = mock_entry.api - camera2.channels[1]._api = mock_entry.api - camera2.channels[2]._api = mock_entry.api - camera2.feature_flags.has_chime = True - mock_entry.api.bootstrap.cameras = { - camera1.id: camera1, - camera2.id: camera2, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, [camera1, camera2, chime]) registry = er.async_get(hass) chime_entry = registry.async_get("button.test_chime_play_chime") @@ -209,6 +172,6 @@ async def test_set_chime_paired_doorbells( blocking=True, ) - mock_entry.api.update_device.assert_called_once_with( - ModelType.CHIME, mock_chime.id, {"cameraIds": [camera1.id, camera2.id]} + ufp.api.update_device.assert_called_once_with( + ModelType.CHIME, chime.id, {"cameraIds": sorted([camera1.id, camera2.id])} ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 7918ea0b6cf..684e3b8e441 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -5,13 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import ( - Camera, - Light, - RecordingMode, - SmartDetectObjectType, - VideoMode, -) +from pyunifiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.switch import ( @@ -23,11 +17,14 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Pla from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, + adopt_devices, assert_entity_counts, enable_entity, ids_from_device_description, + init_entry, + remove_entities, ) CAMERA_SWITCHES_BASIC = [ @@ -42,183 +39,61 @@ CAMERA_SWITCHES_NO_EXTRA = [ ] -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +async def test_switch_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera ): - """Fixture for a single light for testing the switch platform.""" + """Test removing and re-adding a camera device.""" - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) - light_obj = mock_light.copy(deep=True) - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.is_ssh_enabled = False - light_obj.light_device_settings.is_indicator_enabled = False - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() +async def test_switch_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test removing and re-adding a light device.""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + await adopt_devices(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 2, 1) - yield light_obj - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +async def test_switch_setup_no_perm( + hass: HomeAssistant, + ufp: MockUFPFixture, + light: Light, + doorbell: Camera, ): - """Fixture for a single camera for testing the switch platform.""" + """Test switch entity setup for light devices.""" - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.recording_settings.mode = RecordingMode.DETECTIONS - camera_obj.feature_flags.has_led_status = True - camera_obj.feature_flags.has_hdr = True - camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS] - camera_obj.feature_flags.has_privacy_mask = True - camera_obj.feature_flags.has_speaker = True - camera_obj.feature_flags.has_smart_detect = True - camera_obj.feature_flags.smart_detect_types = [ - SmartDetectObjectType.PERSON, - SmartDetectObjectType.VEHICLE, + ufp.api.bootstrap.auth_user.all_permissions = [ + Permission.unifi_dict_to_dict({"rawPermission": "light:read:*"}) ] - camera_obj.is_ssh_enabled = False - camera_obj.led_settings.is_enabled = False - camera_obj.hdr_mode = False - camera_obj.video_mode = VideoMode.DEFAULT - camera_obj.remove_privacy_zone() - camera_obj.speaker_settings.are_system_sounds_enabled = False - camera_obj.osd_settings.is_name_enabled = False - camera_obj.osd_settings.is_date_enabled = False - camera_obj.osd_settings.is_logo_enabled = False - camera_obj.osd_settings.is_debug_enabled = False - camera_obj.smart_detect_settings.object_types = [] - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } + await init_entry(hass, ufp, [light, doorbell]) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SWITCH, 12, 11) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_none") -async def camera_none_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the switch platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.recording_settings.mode = RecordingMode.DETECTIONS - camera_obj.feature_flags.has_led_status = False - camera_obj.feature_flags.has_hdr = False - camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] - camera_obj.feature_flags.has_privacy_mask = False - camera_obj.feature_flags.has_speaker = False - camera_obj.feature_flags.has_smart_detect = False - camera_obj.is_ssh_enabled = False - camera_obj.osd_settings.is_name_enabled = False - camera_obj.osd_settings.is_date_enabled = False - camera_obj.osd_settings.is_logo_enabled = False - camera_obj.osd_settings.is_debug_enabled = False - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SWITCH, 5, 4) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_privacy") -async def camera_privacy_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the switch platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy(deep=True) - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.recording_settings.mode = RecordingMode.NEVER - camera_obj.feature_flags.has_led_status = False - camera_obj.feature_flags.has_hdr = False - camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] - camera_obj.feature_flags.has_privacy_mask = True - camera_obj.feature_flags.has_speaker = False - camera_obj.feature_flags.has_smart_detect = False - camera_obj.add_privacy_zone() - camera_obj.is_ssh_enabled = False - camera_obj.osd_settings.is_name_enabled = False - camera_obj.osd_settings.is_date_enabled = False - camera_obj.osd_settings.is_logo_enabled = False - camera_obj.osd_settings.is_debug_enabled = False - - mock_entry.api.bootstrap.reset_objects() - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SWITCH, 6, 5) - - yield camera_obj - - Camera.__config__.validate_assignment = True + assert_entity_counts(hass, Platform.SWITCH, 0, 0) async def test_switch_setup_light( hass: HomeAssistant, - mock_entry: MockEntityFixture, + ufp: MockUFPFixture, light: Light, ): """Test switch entity setup for light devices.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + entity_registry = er.async_get(hass) description = LIGHT_SWITCHES[1] @@ -238,7 +113,7 @@ async def test_switch_setup_light( description = LIGHT_SWITCHES[0] - unique_id = f"{light.id}_{description.key}" + unique_id = f"{light.mac}_{description.key}" entity_id = f"switch.test_light_{description.name.lower().replace(' ', '_')}" entity = entity_registry.async_get(entity_id) @@ -246,7 +121,7 @@ async def test_switch_setup_light( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -256,14 +131,67 @@ async def test_switch_setup_light( async def test_switch_setup_camera_all( hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: Camera, + ufp: MockUFPFixture, + doorbell: Camera, ): """Test switch entity setup for camera devices (all enabled feature flags).""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + entity_registry = er.async_get(hass) for description in CAMERA_SWITCHES_BASIC: + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, doorbell, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = CAMERA_SWITCHES[0] + + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_") + ) + unique_id = f"{doorbell.mac}_{description.key}" + entity_id = f"switch.test_camera_{description_entity_name}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_setup_camera_none( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: Camera, +): + """Test switch entity setup for camera devices (no enabled feature flags).""" + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.SWITCH, 6, 5) + + entity_registry = er.async_get(hass) + + for description in CAMERA_SWITCHES_BASIC: + if description.ufp_required_field is not None: + continue + unique_id, entity_id = ids_from_device_description( Platform.SWITCH, camera, description ) @@ -282,7 +210,7 @@ async def test_switch_setup_camera_all( description_entity_name = ( description.name.lower().replace(":", "").replace(" ", "_") ) - unique_id = f"{camera.id}_{description.key}" + unique_id = f"{camera.mac}_{description.key}" entity_id = f"switch.test_camera_{description_entity_name}" entity = entity_registry.async_get(entity_id) @@ -290,7 +218,7 @@ async def test_switch_setup_camera_all( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -298,56 +226,14 @@ async def test_switch_setup_camera_all( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_switch_setup_camera_none( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera_none: Camera, +async def test_switch_light_status( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): - """Test switch entity setup for camera devices (no enabled feature flags).""" - - entity_registry = er.async_get(hass) - - for description in CAMERA_SWITCHES_BASIC: - if description.ufp_required_field is not None: - continue - - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, camera_none, description - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.unique_id == unique_id - - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - description = CAMERA_SWITCHES[0] - - description_entity_name = ( - description.name.lower().replace(":", "").replace(" ", "_") - ) - unique_id = f"{camera_none.id}_{description.key}" - entity_id = f"switch.test_camera_{description_entity_name}" - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.disabled is True - assert entity.unique_id == unique_id - - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - -async def test_switch_light_status(hass: HomeAssistant, light: Light): """Tests status light switch for lights.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + description = LIGHT_SWITCHES[1] light.__fields__["set_status_light"] = Mock() @@ -369,44 +255,53 @@ async def test_switch_light_status(hass: HomeAssistant, light: Light): async def test_switch_camera_ssh( - hass: HomeAssistant, camera: Camera, mock_entry: MockEntityFixture + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Tests SSH switch for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[0] - camera.__fields__["set_ssh"] = Mock() - camera.set_ssh = AsyncMock() + doorbell.__fields__["set_ssh"] = Mock() + doorbell.set_ssh = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + await enable_entity(hass, ufp.entry.entry_id, entity_id) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_ssh.assert_called_once_with(True) + doorbell.set_ssh.assert_called_once_with(True) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_ssh.assert_called_with(False) + doorbell.set_ssh.assert_called_with(False) @pytest.mark.parametrize("description", CAMERA_SWITCHES_NO_EXTRA) async def test_switch_camera_simple( - hass: HomeAssistant, camera: Camera, description: ProtectSwitchEntityDescription + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + description: ProtectSwitchEntityDescription, ): """Tests all simple switches for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + assert description.ufp_set_method is not None - camera.__fields__[description.ufp_set_method] = Mock() - setattr(camera, description.ufp_set_method, AsyncMock()) - set_method = getattr(camera, description.ufp_set_method) + doorbell.__fields__[description.ufp_set_method] = Mock() + setattr(doorbell, description.ufp_set_method, AsyncMock()) + set_method = getattr(doorbell, description.ufp_set_method) - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -421,70 +316,82 @@ async def test_switch_camera_simple( set_method.assert_called_with(False) -async def test_switch_camera_highfps(hass: HomeAssistant, camera: Camera): +async def test_switch_camera_highfps( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +): """Tests High FPS switch for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[3] - camera.__fields__["set_video_mode"] = Mock() - camera.set_video_mode = AsyncMock() + doorbell.__fields__["set_video_mode"] = Mock() + doorbell.set_video_mode = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_video_mode.assert_called_once_with(VideoMode.HIGH_FPS) + doorbell.set_video_mode.assert_called_once_with(VideoMode.HIGH_FPS) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_video_mode.assert_called_with(VideoMode.DEFAULT) + doorbell.set_video_mode.assert_called_with(VideoMode.DEFAULT) -async def test_switch_camera_privacy(hass: HomeAssistant, camera: Camera): +async def test_switch_camera_privacy( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +): """Tests Privacy Mode switch for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[4] - camera.__fields__["set_privacy"] = Mock() - camera.set_privacy = AsyncMock() + doorbell.__fields__["set_privacy"] = Mock() + doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_privacy.assert_called_once_with(True, 0, RecordingMode.NEVER) + doorbell.set_privacy.assert_called_once_with(True, 0, RecordingMode.NEVER) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_privacy.assert_called_with( - False, camera.mic_volume, camera.recording_settings.mode + doorbell.set_privacy.assert_called_with( + False, doorbell.mic_volume, doorbell.recording_settings.mode ) async def test_switch_camera_privacy_already_on( - hass: HomeAssistant, camera_privacy: Camera + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Tests Privacy Mode switch for cameras with privacy mode defaulted on.""" + doorbell.add_privacy_zone() + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[4] - camera_privacy.__fields__["set_privacy"] = Mock() - camera_privacy.set_privacy = AsyncMock() + doorbell.__fields__["set_privacy"] = Mock() + doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description( - Platform.SWITCH, camera_privacy, description - ) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera_privacy.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS) + doorbell.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py new file mode 100644 index 00000000000..260c6996128 --- /dev/null +++ b/tests/components/unifiprotect/utils.py @@ -0,0 +1,224 @@ +"""Test helpers for UniFi Protect.""" +# pylint: disable=protected-access +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Callable, Sequence +from unittest.mock import Mock + +from pyunifiprotect import ProtectApiClient +from pyunifiprotect.data import ( + Bootstrap, + Camera, + Event, + EventType, + ModelType, + ProtectAdoptableDeviceModel, + WSSubscriptionMessage, +) +from pyunifiprotect.data.bootstrap import ProtectDeviceRef +from pyunifiprotect.test_util.anonymize import random_hex + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityDescription +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@dataclass +class MockUFPFixture: + """Mock for NVR.""" + + entry: MockConfigEntry + api: ProtectApiClient + ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None + + def ws_msg(self, msg: WSSubscriptionMessage) -> Any: + """Emit WS message for testing.""" + + if self.ws_subscription is not None: + return self.ws_subscription(msg) + + +def reset_objects(bootstrap: Bootstrap): + """Reset bootstrap objects.""" + + bootstrap.cameras = {} + bootstrap.lights = {} + bootstrap.sensors = {} + bootstrap.viewers = {} + bootstrap.events = {} + bootstrap.doorlocks = {} + bootstrap.chimes = {} + + +async def time_changed(hass: HomeAssistant, seconds: int) -> None: + """Trigger time changed.""" + next_update = dt_util.utcnow() + timedelta(seconds) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + +async def enable_entity( + hass: HomeAssistant, entry_id: str, entity_id: str +) -> er.RegistryEntry: + """Enable a disabled entity.""" + entity_registry = er.async_get(hass) + + updated_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) + assert not updated_entity.disabled + await hass.config_entries.async_reload(entry_id) + await hass.async_block_till_done() + + return updated_entity + + +def assert_entity_counts( + hass: HomeAssistant, platform: Platform, total: int, enabled: int +) -> None: + """Assert entity counts for a given platform.""" + + entity_registry = er.async_get(hass) + + entities = [ + e for e in entity_registry.entities if split_entity_id(e)[0] == platform.value + ] + + assert len(entities) == total + assert len(hass.states.async_all(platform.value)) == enabled + + +def normalize_name(name: str) -> str: + """Normalize name.""" + + return name.lower().replace(":", "").replace(" ", "_").replace("-", "_") + + +def ids_from_device_description( + platform: Platform, + device: ProtectAdoptableDeviceModel, + description: EntityDescription, +) -> tuple[str, str]: + """Return expected unique_id and entity_id for a give platform/device/description combination.""" + + entity_name = normalize_name(device.display_name) + description_entity_name = normalize_name(str(description.name)) + + unique_id = f"{device.mac}_{description.key}" + entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" + + return unique_id, entity_id + + +def generate_random_ids() -> tuple[str, str]: + """Generate random IDs for device.""" + + return random_hex(24).lower(), random_hex(12).upper() + + +def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None: + """Regenerate the IDs on UFP device.""" + + device.id, device.mac = generate_random_ids() + + +def add_device_ref(bootstrap: Bootstrap, device: ProtectAdoptableDeviceModel) -> None: + """Manually add device ref to bootstrap for lookup.""" + + ref = ProtectDeviceRef(id=device.id, model=device.model) + bootstrap.id_lookup[device.id] = ref + bootstrap.mac_lookup[device.mac.lower()] = ref + + +def add_device( + bootstrap: Bootstrap, device: ProtectAdoptableDeviceModel, regenerate_ids: bool +) -> None: + """Add test device to bootstrap.""" + + if device.model is None: + return + + device._api = bootstrap.api + if isinstance(device, Camera): + for channel in device.channels: + channel._api = bootstrap.api + + if regenerate_ids: + regenerate_device_ids(device) + device._initial_data = device.dict() + + devices = getattr(bootstrap, f"{device.model.value}s") + devices[device.id] = device + add_device_ref(bootstrap, device) + + +async def init_entry( + hass: HomeAssistant, + ufp: MockUFPFixture, + devices: Sequence[ProtectAdoptableDeviceModel], + regenerate_ids: bool = True, +) -> None: + """Initialize Protect entry with given devices.""" + + reset_objects(ufp.api.bootstrap) + for device in devices: + add_device(ufp.api.bootstrap, device, regenerate_ids) + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + +async def remove_entities( + hass: HomeAssistant, + ufp_devices: list[ProtectAdoptableDeviceModel], +) -> None: + """Remove all entities for given Protect devices.""" + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for ufp_device in ufp_devices: + if not ufp_device.is_adopted_by_us: + continue + + name = ufp_device.display_name.replace(" ", "_").lower() + entity = entity_registry.async_get(f"{Platform.SENSOR}.{name}_uptime") + assert entity is not None + + device_id = entity.device_id + for reg in list(entity_registry.entities.values()): + if reg.device_id == device_id: + entity_registry.async_remove(reg.entity_id) + device_registry.async_remove_device(device_id) + + await hass.async_block_till_done() + + +async def adopt_devices( + hass: HomeAssistant, + ufp: MockUFPFixture, + ufp_devices: list[ProtectAdoptableDeviceModel], +): + """Emit WS to re-adopt give Protect devices.""" + + for ufp_device in ufp_devices: + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = Event( + api=ufp_device.api, + id=random_hex(24), + smart_detect_types=[], + smart_detect_event_ids=[], + type=EventType.DEVICE_ADOPTED, + start=dt_util.utcnow(), + score=100, + metadata={"device_id": ufp_device.id}, + model=ModelType.EVENT, + ) + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index d4fe2ce64ae..c50c3e97713 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -422,6 +422,12 @@ async def test_active_child_state(hass, mock_states): await ump.async_update() assert mock_states.mock_mp_1.entity_id == ump._child_state.entity_id + mock_states.mock_mp_1._state = STATE_PAUSED + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert mock_states.mock_mp_2.entity_id == ump._child_state.entity_id + mock_states.mock_mp_1._state = STATE_OFF mock_states.mock_mp_1.async_schedule_update_ha_state() await hass.async_block_till_done() diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index d340a8dfa3f..d1263a720af 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.update.const import ( ATTR_IN_PROGRESS, diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 687518bb46d..0d3e869db35 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -156,10 +156,7 @@ async def ssdp_no_discovery(): ) as mock_register, patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[], - ) as mock_get_info, patch( - "homeassistant.components.upnp.config_flow.SSDP_SEARCH_TIMEOUT", - 0.1, - ): + ) as mock_get_info: yield (mock_register, mock_get_info) diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index ea8b3381cd1..66d84fe0862 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,7 +1,7 @@ """Test UPnP/IGD config flow.""" from copy import deepcopy -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -14,6 +14,7 @@ from homeassistant.components.upnp.const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DOMAIN, + ST_IGD_V1, ) from homeassistant.core import HomeAssistant @@ -75,6 +76,7 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ + ssdp.ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ), @@ -83,6 +85,27 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): assert result["reason"] == "incomplete_discovery" +@pytest.mark.usefixtures("mock_get_source_ip") +async def test_flow_ssdp_non_igd_device(hass: HomeAssistant): + """Test config flow: incomplete discovery through ssdp.""" + # Discovered via step ssdp. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn=TEST_USN, + ssdp_st=TEST_ST, + ssdp_location=TEST_LOCATION, + upnp={ + ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD + ssdp.ATTR_UPNP_UDN: TEST_UDN, + }, + ), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "non_igd_device" + + @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", @@ -341,97 +364,3 @@ async def test_flow_user_no_discovery(hass: HomeAssistant): ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "no_devices_found" - - -@pytest.mark.usefixtures( - "ssdp_instant_discovery", - "mock_setup_entry", - "mock_get_source_ip", - "mock_mac_address_from_host", -) -async def test_flow_import(hass: HomeAssistant): - """Test config flow: configured through configuration.yaml.""" - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == TEST_FRIENDLY_NAME - assert result["data"] == { - CONFIG_ENTRY_ST: TEST_ST, - CONFIG_ENTRY_UDN: TEST_UDN, - CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, - CONFIG_ENTRY_LOCATION: TEST_LOCATION, - CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, - } - - -@pytest.mark.usefixtures( - "mock_get_source_ip", -) -async def test_flow_import_incomplete_discovery(hass: HomeAssistant): - """Test config flow: configured through configuration.yaml, but incomplete discovery.""" - incomplete_discovery = ssdp.SsdpServiceInfo( - ssdp_usn=TEST_USN, - ssdp_st=TEST_ST, - ssdp_location=TEST_LOCATION, - upnp={ - # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. - }, - ) - - async def register_callback(hass, callback, match_dict): - """Immediately do callback.""" - await callback(incomplete_discovery, ssdp.SsdpChange.ALIVE) - return MagicMock() - - with patch( - "homeassistant.components.ssdp.async_register_callback", - side_effect=register_callback, - ), patch( - "homeassistant.components.upnp.ssdp.async_get_discovery_info_by_st", - return_value=[incomplete_discovery], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "incomplete_discovery" - - -@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") -async def test_flow_import_already_configured(hass: HomeAssistant): - """Test config flow: configured through configuration.yaml, but existing config entry.""" - # Existing entry. - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_USN, - data={ - CONFIG_ENTRY_ST: TEST_ST, - CONFIG_ENTRY_UDN: TEST_UDN, - CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, - CONFIG_ENTRY_LOCATION: TEST_LOCATION, - CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, - }, - state=config_entries.ConfigEntryState.LOADED, - ) - entry.add_to_hass(hass) - - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("ssdp_no_discovery", "mock_get_source_ip") -async def test_flow_import_no_devices_found(hass: HomeAssistant): - """Test config flow: no devices found, configured through configuration.yaml.""" - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "no_devices_found" diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py index ee845701d81..b8fcbbcbe7d 100644 --- a/tests/components/usgs_earthquakes_feed/test_geo_location.py +++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py @@ -1,6 +1,9 @@ """The tests for the USGS Earthquake Hazards Program Feed platform.""" import datetime -from unittest.mock import MagicMock, call, patch +from unittest.mock import ANY, MagicMock, call, patch + +from aio_geojson_usgs_earthquakes import UsgsEarthquakeHazardsProgramFeed +from freezegun import freeze_time from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -111,11 +114,10 @@ async def test_setup(hass): # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "geojson_client.usgs_earthquake_hazards_program_feed." - "UsgsEarthquakeHazardsProgramFeed" - ) as mock_feed: - mock_feed.return_value.update.return_value = ( + with freeze_time(utcnow), patch( + "aio_geojson_client.feed.GeoJsonFeed.update" + ) as mock_feed_update: + mock_feed_update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], ) @@ -184,9 +186,9 @@ async def test_setup(hass): } assert round(abs(float(state.state) - 25.5), 7) == 0 - # Simulate an update - one existing, one new entry, + # Simulate an update - two existing, one new entry, # one outdated entry - mock_feed.return_value.update.return_value = ( + mock_feed_update.return_value = ( "OK", [mock_entry_1, mock_entry_4, mock_entry_3], ) @@ -198,7 +200,7 @@ async def test_setup(hass): # Simulate an update - empty data, but successful update, # so no changes to entities. - mock_feed.return_value.update.return_value = "OK_NO_DATA", None + mock_feed_update.return_value = "OK_NO_DATA", None async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) await hass.async_block_till_done() @@ -206,7 +208,7 @@ async def test_setup(hass): assert len(all_states) == 3 # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = "ERROR", None + mock_feed_update.return_value = "ERROR", None async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) await hass.async_block_till_done() @@ -220,10 +222,12 @@ async def test_setup_with_custom_location(hass): mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 20.5, (-31.1, 150.1)) with patch( - "geojson_client.usgs_earthquake_hazards_program_feed." - "UsgsEarthquakeHazardsProgramFeed" - ) as mock_feed: - mock_feed.return_value.update.return_value = "OK", [mock_entry_1] + "aio_geojson_usgs_earthquakes.feed_manager.UsgsEarthquakeHazardsProgramFeed", + wraps=UsgsEarthquakeHazardsProgramFeed, + ) as mock_feed, patch( + "aio_geojson_client.feed.GeoJsonFeed.update" + ) as mock_feed_update: + mock_feed_update.return_value = "OK", [mock_entry_1] with assert_setup_component(1, geo_location.DOMAIN): assert await async_setup_component( @@ -240,6 +244,7 @@ async def test_setup_with_custom_location(hass): assert len(all_states) == 1 assert mock_feed.call_args == call( + ANY, (15.1, 25.2), "past_hour_m25_earthquakes", filter_minimum_magnitude=0.0, diff --git a/tests/components/vacuum/test_recorder.py b/tests/components/vacuum/test_recorder.py index 6267091b984..040cc9105aa 100644 --- a/tests/components/vacuum/test_recorder.py +++ b/tests/components/vacuum/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import vacuum -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.vacuum import ATTR_FAN_SPEED_LIST from homeassistant.const import ATTR_FRIENDLY_NAME diff --git a/tests/components/water_heater/test_recorder.py b/tests/components/water_heater/test_recorder.py index 4a70fc12c8f..b6670152e3f 100644 --- a/tests/components/water_heater/test_recorder.py +++ b/tests/components/water_heater/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import water_heater -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.water_heater import ( ATTR_MAX_TEMP, diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 9849a6abe18..814d3b7857c 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1,4 +1,6 @@ """The test for weather entity.""" +from datetime import datetime + import pytest from pytest import approx @@ -9,19 +11,44 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, + ROUNDING_PRECISION, + Forecast, + WeatherEntity, + round_temperature, ) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + LENGTH_INCHES, + LENGTH_KILOMETERS, LENGTH_MILES, LENGTH_MILLIMETERS, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + PRESSURE_HPA, PRESSURE_INHG, + PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.distance import convert as convert_distance from homeassistant.util.pressure import convert as convert_pressure @@ -29,11 +56,75 @@ from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from tests.testing_config.custom_components.test import weather as WeatherPlatform -async def create_entity(hass, **kwargs): + +class MockWeatherEntity(WeatherEntity): + """Mock a Weather Entity.""" + + def __init__(self) -> None: + """Initiate Entity.""" + super().__init__() + self._attr_condition = ATTR_CONDITION_SUNNY + self._attr_native_precipitation_unit = LENGTH_MILLIMETERS + self._attr_native_pressure = 10 + self._attr_native_pressure_unit = PRESSURE_HPA + self._attr_native_temperature = 20 + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_native_visibility = 30 + self._attr_native_visibility_unit = LENGTH_KILOMETERS + self._attr_native_wind_speed = 3 + self._attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + self._attr_forecast = [ + Forecast( + datetime=datetime(2022, 6, 20, 20, 00, 00), + native_precipitation=1, + native_temperature=20, + ) + ] + + +class MockWeatherEntityPrecision(WeatherEntity): + """Mock a Weather Entity with precision.""" + + def __init__(self) -> None: + """Initiate Entity.""" + super().__init__() + self._attr_condition = ATTR_CONDITION_SUNNY + self._attr_native_temperature = 20.3 + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_precision = PRECISION_HALVES + + +class MockWeatherEntityCompat(WeatherEntity): + """Mock a Weather Entity using old attributes.""" + + def __init__(self) -> None: + """Initiate Entity.""" + super().__init__() + self._attr_condition = ATTR_CONDITION_SUNNY + self._attr_precipitation_unit = LENGTH_MILLIMETERS + self._attr_pressure = 10 + self._attr_pressure_unit = PRESSURE_HPA + self._attr_temperature = 20 + self._attr_temperature_unit = TEMP_CELSIUS + self._attr_visibility = 30 + self._attr_visibility_unit = LENGTH_KILOMETERS + self._attr_wind_speed = 3 + self._attr_wind_speed_unit = SPEED_METERS_PER_SECOND + self._attr_forecast = [ + Forecast( + datetime=datetime(2022, 6, 20, 20, 00, 00), + precipitation=1, + temperature=20, + ) + ] + + +async def create_entity(hass: HomeAssistant, **kwargs): """Create the weather entity to run tests on.""" - kwargs = {"temperature": None, "temperature_unit": None, **kwargs} - platform = getattr(hass.components, "test.weather") + kwargs = {"native_temperature": None, "native_temperature_unit": None, **kwargs} + platform: WeatherPlatform = getattr(hass.components, "test.weather") platform.init(empty=True) platform.ENTITIES.append( platform.MockWeatherMockForecast( @@ -49,145 +140,741 @@ async def create_entity(hass, **kwargs): return entity0 -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_temperature_conversion( - hass, +@pytest.mark.parametrize("native_unit", (TEMP_FAHRENHEIT, TEMP_CELSIUS)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), +) +async def test_temperature( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test temperature conversion.""" + """Test temperature.""" hass.config.units = unit_system native_value = 38 - native_unit = TEMP_FAHRENHEIT + state_value = convert_temperature(native_value, native_unit, state_unit) entity0 = await create_entity( - hass, temperature=native_value, temperature_unit=native_unit + hass, native_temperature=native_value, native_temperature_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_temperature( - native_value, native_unit, unit_system.temperature_unit - ) + expected = state_value assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( expected, rel=0.1 ) + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit assert float(forecast[ATTR_FORECAST_TEMP]) == approx(expected, rel=0.1) assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == approx(expected, rel=0.1) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_pressure_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), +) +async def test_temperature_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test pressure conversion.""" + """Test temperature when the entity does not declare a native unit.""" hass.config.units = unit_system - native_value = 30 - native_unit = PRESSURE_INHG + native_value = 38 + state_value = native_value entity0 = await create_entity( - hass, pressure=native_value, pressure_unit=native_unit + hass, native_temperature=native_value, native_temperature_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + expected, rel=0.1 + ) + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit + assert float(forecast[ATTR_FORECAST_TEMP]) == approx(expected, rel=0.1) + assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == approx(expected, rel=0.1) + + +@pytest.mark.parametrize("native_unit", (PRESSURE_INHG, PRESSURE_INHG)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), +) +async def test_pressure( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test pressure.""" + hass.config.units = unit_system + native_value = 30 + state_value = convert_pressure(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_pressure=native_value, native_pressure_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_pressure(native_value, native_unit, unit_system.pressure_unit) + expected = state_value assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected, rel=1e-2) assert float(forecast[ATTR_FORECAST_PRESSURE]) == approx(expected, rel=1e-2) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_wind_speed_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), +) +async def test_pressure_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test wind speed conversion.""" + """Test pressure when the entity does not declare a native unit.""" hass.config.units = unit_system - native_value = 10 - native_unit = SPEED_METERS_PER_SECOND + native_value = 30 + state_value = native_value entity0 = await create_entity( - hass, wind_speed=native_value, wind_speed_unit=native_unit + hass, native_pressure=native_value, native_pressure_unit=native_unit + ) + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected, rel=1e-2) + assert float(forecast[ATTR_FORECAST_PRESSURE]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize( + "native_unit", + (SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, SPEED_METERS_PER_SECOND), +) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), + (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + ), +) +async def test_wind_speed( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test wind speed.""" + hass.config.units = unit_system + native_value = 10 + state_value = convert_speed(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_speed(native_value, native_unit, unit_system.wind_speed_unit) + expected = state_value assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( expected, rel=1e-2 ) assert float(forecast[ATTR_FORECAST_WIND_SPEED]) == approx(expected, rel=1e-2) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_visibility_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), + (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + ), +) +async def test_wind_speed_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test visibility conversion.""" + """Test wind speed when the entity does not declare a native unit.""" hass.config.units = unit_system native_value = 10 - native_unit = LENGTH_MILES + state_value = native_value entity0 = await create_entity( - hass, visibility=native_value, visibility_unit=native_unit + hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit ) state = hass.states.get(entity0.entity_id) - expected = convert_distance(native_value, native_unit, unit_system.length_unit) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + expected, rel=1e-2 + ) + assert float(forecast[ATTR_FORECAST_WIND_SPEED]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize("native_unit", (LENGTH_MILES, LENGTH_KILOMETERS)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_KILOMETERS, METRIC_SYSTEM), + (LENGTH_MILES, IMPERIAL_SYSTEM), + ), +) +async def test_visibility( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test visibility.""" + hass.config.units = unit_system + native_value = 10 + state_value = convert_distance(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_visibility=native_value, native_visibility_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + expected = state_value assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( expected, rel=1e-2 ) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_precipitation_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_KILOMETERS, METRIC_SYSTEM), + (LENGTH_MILES, IMPERIAL_SYSTEM), + ), +) +async def test_visibility_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test precipitation conversion.""" + """Test visibility when the entity does not declare a native unit.""" hass.config.units = unit_system - native_value = 30 - native_unit = LENGTH_MILLIMETERS + native_value = 10 + state_value = native_value entity0 = await create_entity( - hass, precipitation=native_value, precipitation_unit=native_unit + hass, native_visibility=native_value, native_visibility_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + expected = state_value + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( + expected, rel=1e-2 + ) + + +@pytest.mark.parametrize("native_unit", (LENGTH_INCHES, LENGTH_MILLIMETERS)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_MILLIMETERS, METRIC_SYSTEM), + (LENGTH_INCHES, IMPERIAL_SYSTEM), + ), +) +async def test_precipitation( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test precipitation.""" + hass.config.units = unit_system + native_value = 30 + state_value = convert_distance(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_precipitation=native_value, native_precipitation_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_distance( - native_value, native_unit, unit_system.accumulated_precipitation_unit - ) + expected = state_value assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx(expected, rel=1e-2) +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_MILLIMETERS, METRIC_SYSTEM), + (LENGTH_INCHES, IMPERIAL_SYSTEM), + ), +) +async def test_precipitation_no_unit( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test precipitation when the entity does not declare a native unit.""" + hass.config.units = unit_system + native_value = 30 + state_value = native_value + + entity0 = await create_entity( + hass, native_precipitation=native_value, native_precipitation_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx(expected, rel=1e-2) + + +async def test_wind_bearing_and_ozone( + hass: HomeAssistant, + enable_custom_integrations, +): + """Test wind bearing.""" + wind_bearing_value = 180 + ozone_value = 10 + + entity0 = await create_entity( + hass, wind_bearing=wind_bearing_value, ozone=ozone_value + ) + + state = hass.states.get(entity0.entity_id) + assert float(state.attributes[ATTR_WEATHER_WIND_BEARING]) == 180 + assert float(state.attributes[ATTR_WEATHER_OZONE]) == 10 + + async def test_none_forecast( - hass, + hass: HomeAssistant, enable_custom_integrations, ): """Test that conversion with None values succeeds.""" entity0 = await create_entity( hass, - pressure=None, - pressure_unit=PRESSURE_INHG, - wind_speed=None, - wind_speed_unit=SPEED_METERS_PER_SECOND, - precipitation=None, - precipitation_unit=LENGTH_MILLIMETERS, + native_pressure=None, + native_pressure_unit=PRESSURE_INHG, + native_wind_speed=None, + native_wind_speed_unit=SPEED_METERS_PER_SECOND, + native_precipitation=None, + native_precipitation_unit=LENGTH_MILLIMETERS, ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - assert forecast[ATTR_FORECAST_PRESSURE] is None - assert forecast[ATTR_FORECAST_WIND_SPEED] is None - assert forecast[ATTR_FORECAST_PRECIPITATION] is None + assert forecast.get(ATTR_FORECAST_PRESSURE) is None + assert forecast.get(ATTR_FORECAST_WIND_SPEED) is None + assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None + + +async def test_custom_units(hass: HomeAssistant, enable_custom_integrations) -> None: + """Test custom unit.""" + wind_speed_value = 5 + wind_speed_unit = SPEED_METERS_PER_SECOND + pressure_value = 110 + pressure_unit = PRESSURE_HPA + temperature_value = 20 + temperature_unit = TEMP_CELSIUS + visibility_value = 11 + visibility_unit = LENGTH_KILOMETERS + precipitation_value = 1.1 + precipitation_unit = LENGTH_MILLIMETERS + + set_options = { + "wind_speed_unit": SPEED_MILES_PER_HOUR, + "precipitation_unit": LENGTH_INCHES, + "pressure_unit": PRESSURE_INHG, + "temperature_unit": TEMP_FAHRENHEIT, + "visibility_unit": LENGTH_MILES, + } + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("weather", "test", "very_unique") + entity_registry.async_update_entity_options(entry.entity_id, "weather", set_options) + await hass.async_block_till_done() + + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", + condition=ATTR_CONDITION_SUNNY, + native_temperature=temperature_value, + native_temperature_unit=temperature_unit, + native_wind_speed=wind_speed_value, + native_wind_speed_unit=wind_speed_unit, + native_pressure=pressure_value, + native_pressure_unit=pressure_unit, + native_visibility=visibility_value, + native_visibility_unit=visibility_unit, + native_precipitation=precipitation_value, + native_precipitation_unit=precipitation_unit, + unique_id="very_unique", + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected_wind_speed = round( + convert_speed(wind_speed_value, wind_speed_unit, SPEED_MILES_PER_HOUR), + ROUNDING_PRECISION, + ) + expected_temperature = convert_temperature( + temperature_value, temperature_unit, TEMP_FAHRENHEIT + ) + expected_pressure = round( + convert_pressure(pressure_value, pressure_unit, PRESSURE_INHG), + ROUNDING_PRECISION, + ) + expected_visibility = round( + convert_distance(visibility_value, visibility_unit, LENGTH_MILES), + ROUNDING_PRECISION, + ) + expected_precipitation = round( + convert_distance(precipitation_value, precipitation_unit, LENGTH_INCHES), + ROUNDING_PRECISION, + ) + + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + expected_wind_speed + ) + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + expected_temperature, rel=0.1 + ) + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected_pressure) + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( + expected_visibility + ) + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx( + expected_precipitation, rel=1e-2 + ) + + assert ( + state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] + == set_options["precipitation_unit"] + ) + assert state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == set_options["pressure_unit"] + assert ( + state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] + == set_options["temperature_unit"] + ) + assert ( + state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == set_options["visibility_unit"] + ) + assert ( + state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == set_options["wind_speed_unit"] + ) + + +async def test_backwards_compatibility( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test backwards compatibility.""" + wind_speed_value = 5 + wind_speed_unit = SPEED_METERS_PER_SECOND + pressure_value = 110000 + pressure_unit = PRESSURE_PA + temperature_value = 20 + temperature_unit = TEMP_CELSIUS + visibility_value = 11 + visibility_unit = LENGTH_KILOMETERS + precipitation_value = 1 + precipitation_unit = LENGTH_MILLIMETERS + + hass.config.units = METRIC_SYSTEM + + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecastCompat( + name="Test", + condition=ATTR_CONDITION_SUNNY, + temperature=temperature_value, + temperature_unit=temperature_unit, + wind_speed=wind_speed_value, + wind_speed_unit=wind_speed_unit, + pressure=pressure_value, + pressure_unit=pressure_unit, + visibility=visibility_value, + visibility_unit=visibility_unit, + precipitation=precipitation_value, + precipitation_unit=precipitation_unit, + unique_id="very_unique", + ) + ) + platform.ENTITIES.append( + platform.MockWeatherMockForecastCompat( + name="Test2", + condition=ATTR_CONDITION_SUNNY, + temperature=temperature_value, + temperature_unit=temperature_unit, + wind_speed=wind_speed_value, + pressure=pressure_value, + visibility=visibility_value, + precipitation=precipitation_value, + unique_id="very_unique2", + ) + ) + + entity0 = platform.ENTITIES[0] + entity1 = platform.ENTITIES[1] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test2"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + state1 = hass.states.get(entity1.entity_id) + forecast1 = state1.attributes[ATTR_FORECAST][0] + + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + wind_speed_value * 3.6 + ) + assert state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == SPEED_KILOMETERS_PER_HOUR + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + temperature_value, rel=0.1 + ) + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == TEMP_CELSIUS + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx( + pressure_value / 100 + ) + assert state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == PRESSURE_HPA + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx(visibility_value) + assert state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == LENGTH_KILOMETERS + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx( + precipitation_value, rel=1e-2 + ) + assert state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == LENGTH_MILLIMETERS + + assert float(state1.attributes[ATTR_WEATHER_WIND_SPEED]) == approx(wind_speed_value) + assert state1.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == SPEED_KILOMETERS_PER_HOUR + assert float(state1.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + temperature_value, rel=0.1 + ) + assert state1.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == TEMP_CELSIUS + assert float(state1.attributes[ATTR_WEATHER_PRESSURE]) == approx(pressure_value) + assert state1.attributes[ATTR_WEATHER_PRESSURE_UNIT] == PRESSURE_HPA + assert float(state1.attributes[ATTR_WEATHER_VISIBILITY]) == approx(visibility_value) + assert state1.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == LENGTH_KILOMETERS + assert float(forecast1[ATTR_FORECAST_PRECIPITATION]) == approx( + precipitation_value, rel=1e-2 + ) + assert state1.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == LENGTH_MILLIMETERS + + +async def test_backwards_compatibility_convert_values( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test backward compatibility for converting values.""" + wind_speed_value = 5 + wind_speed_unit = SPEED_METERS_PER_SECOND + pressure_value = 110000 + pressure_unit = PRESSURE_PA + temperature_value = 20 + temperature_unit = TEMP_CELSIUS + visibility_value = 11 + visibility_unit = LENGTH_KILOMETERS + precipitation_value = 1 + precipitation_unit = LENGTH_MILLIMETERS + + hass.config.units = IMPERIAL_SYSTEM + + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecastCompat( + name="Test", + condition=ATTR_CONDITION_SUNNY, + temperature=temperature_value, + temperature_unit=temperature_unit, + wind_speed=wind_speed_value, + wind_speed_unit=wind_speed_unit, + pressure=pressure_value, + pressure_unit=pressure_unit, + visibility=visibility_value, + visibility_unit=visibility_unit, + precipitation=precipitation_value, + precipitation_unit=precipitation_unit, + unique_id="very_unique", + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + + expected_wind_speed = round( + convert_speed(wind_speed_value, wind_speed_unit, SPEED_MILES_PER_HOUR), + ROUNDING_PRECISION, + ) + expected_temperature = convert_temperature( + temperature_value, temperature_unit, TEMP_FAHRENHEIT + ) + expected_pressure = round( + convert_pressure(pressure_value, pressure_unit, PRESSURE_INHG), + ROUNDING_PRECISION, + ) + expected_visibility = round( + convert_distance(visibility_value, visibility_unit, LENGTH_MILES), + ROUNDING_PRECISION, + ) + expected_precipitation = round( + convert_distance(precipitation_value, precipitation_unit, LENGTH_INCHES), + ROUNDING_PRECISION, + ) + + assert state.attributes == { + ATTR_FORECAST: [ + { + ATTR_FORECAST_PRECIPITATION: approx(expected_precipitation, rel=0.1), + ATTR_FORECAST_PRESSURE: approx(expected_pressure, rel=0.1), + ATTR_FORECAST_TEMP: approx(expected_temperature, rel=0.1), + ATTR_FORECAST_TEMP_LOW: approx(expected_temperature, rel=0.1), + ATTR_FORECAST_WIND_BEARING: None, + ATTR_FORECAST_WIND_SPEED: approx(expected_wind_speed, rel=0.1), + } + ], + ATTR_FRIENDLY_NAME: "Test", + ATTR_WEATHER_PRECIPITATION_UNIT: LENGTH_INCHES, + ATTR_WEATHER_PRESSURE: approx(expected_pressure, rel=0.1), + ATTR_WEATHER_PRESSURE_UNIT: PRESSURE_INHG, + ATTR_WEATHER_TEMPERATURE: approx(expected_temperature, rel=0.1), + ATTR_WEATHER_TEMPERATURE_UNIT: TEMP_FAHRENHEIT, + ATTR_WEATHER_VISIBILITY: approx(expected_visibility, rel=0.1), + ATTR_WEATHER_VISIBILITY_UNIT: LENGTH_MILES, + ATTR_WEATHER_WIND_SPEED: approx(expected_wind_speed, rel=0.1), + ATTR_WEATHER_WIND_SPEED_UNIT: SPEED_MILES_PER_HOUR, + } + + +async def test_backwards_compatibility_round_temperature(hass: HomeAssistant) -> None: + """Test backward compatibility for rounding temperature.""" + + assert round_temperature(20.3, PRECISION_HALVES) == 20.5 + assert round_temperature(20.3, PRECISION_TENTHS) == 20.3 + assert round_temperature(20.3, PRECISION_WHOLE) == 20 + assert round_temperature(None, PRECISION_WHOLE) is None + + +async def test_attr(hass: HomeAssistant) -> None: + """Test the _attr attributes.""" + + weather = MockWeatherEntity() + weather.hass = hass + + assert weather.condition == ATTR_CONDITION_SUNNY + assert weather.native_precipitation_unit == LENGTH_MILLIMETERS + assert weather._precipitation_unit == LENGTH_MILLIMETERS + assert weather.native_pressure == 10 + assert weather.native_pressure_unit == PRESSURE_HPA + assert weather._pressure_unit == PRESSURE_HPA + assert weather.native_temperature == 20 + assert weather.native_temperature_unit == TEMP_CELSIUS + assert weather._temperature_unit == TEMP_CELSIUS + assert weather.native_visibility == 30 + assert weather.native_visibility_unit == LENGTH_KILOMETERS + assert weather._visibility_unit == LENGTH_KILOMETERS + assert weather.native_wind_speed == 3 + assert weather.native_wind_speed_unit == SPEED_METERS_PER_SECOND + assert weather._wind_speed_unit == SPEED_KILOMETERS_PER_HOUR + + +async def test_attr_compatibility(hass: HomeAssistant) -> None: + """Test the _attr attributes in compatibility mode.""" + + weather = MockWeatherEntityCompat() + weather.hass = hass + + assert weather.condition == ATTR_CONDITION_SUNNY + assert weather._precipitation_unit == LENGTH_MILLIMETERS + assert weather.pressure == 10 + assert weather._pressure_unit == PRESSURE_HPA + assert weather.temperature == 20 + assert weather._temperature_unit == TEMP_CELSIUS + assert weather.visibility == 30 + assert weather.visibility_unit == LENGTH_KILOMETERS + assert weather.wind_speed == 3 + assert weather._wind_speed_unit == SPEED_KILOMETERS_PER_HOUR + + forecast_entry = [ + Forecast( + datetime=datetime(2022, 6, 20, 20, 00, 00), + precipitation=1, + temperature=20, + ) + ] + + assert weather.forecast == forecast_entry + + assert weather.state_attributes == { + ATTR_FORECAST: forecast_entry, + ATTR_WEATHER_PRESSURE: 10.0, + ATTR_WEATHER_PRESSURE_UNIT: PRESSURE_HPA, + ATTR_WEATHER_TEMPERATURE: 20.0, + ATTR_WEATHER_TEMPERATURE_UNIT: TEMP_CELSIUS, + ATTR_WEATHER_VISIBILITY: 30.0, + ATTR_WEATHER_VISIBILITY_UNIT: LENGTH_KILOMETERS, + ATTR_WEATHER_WIND_SPEED: 3.0 * 3.6, + ATTR_WEATHER_WIND_SPEED_UNIT: SPEED_KILOMETERS_PER_HOUR, + ATTR_WEATHER_PRECIPITATION_UNIT: LENGTH_MILLIMETERS, + } + + +async def test_precision_for_temperature(hass: HomeAssistant) -> None: + """Test the precision for temperature.""" + + weather = MockWeatherEntityPrecision() + weather.hass = hass + + assert weather.condition == ATTR_CONDITION_SUNNY + assert weather.native_temperature == 20.3 + assert weather._temperature_unit == TEMP_CELSIUS + assert weather.precision == PRECISION_HALVES + + assert weather.state_attributes[ATTR_WEATHER_TEMPERATURE] == 20.5 diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 9f2e5289013..ef1998f734c 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.weather import ATTR_FORECAST, DOMAIN from homeassistant.core import HomeAssistant, State diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 4d3302f7c13..0f4695596fc 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -619,12 +619,15 @@ async def test_states_filters_visible(hass, hass_admin_user, websocket_client): async def test_get_states_not_allows_nan(hass, websocket_client): - """Test get_states command not allows NaN floats.""" + """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")}) hass.states.async_set("greeting.bye", "universe") await websocket_client.send_json({"id": 5, "type": "get_states"}) + bad = dict(hass.states.get("greeting.bad").as_dict()) + bad["attributes"] = dict(bad["attributes"]) + bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -632,6 +635,7 @@ async def test_get_states_not_allows_nan(hass, websocket_client): assert msg["success"] assert msg["result"] == [ hass.states.get("greeting.hello").as_dict(), + bad, hass.states.get("greeting.bye").as_dict(), ] diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 1a5998c1f94..6dc7b1e5d2c 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -97,6 +97,15 @@ def pywemo_device_fixture(pywemo_registry, pywemo_model): yield pywemo_device +@pytest.fixture(name="pywemo_dli_device") +def pywemo_dli_device_fixture(pywemo_registry, pywemo_model): + """Fixture for Digital Loggers emulated instances.""" + with create_pywemo_device(pywemo_registry, pywemo_model) as pywemo_dli_device: + pywemo_dli_device.model_name = "DLI emulated Belkin Socket" + pywemo_dli_device.serialnumber = "1234567891" + yield pywemo_dli_device + + @pytest.fixture(name="wemo_entity_suffix") def wemo_entity_suffix_fixture(): """Fixture to select a specific entity for wemo_entity.""" @@ -129,3 +138,9 @@ async def async_create_wemo_entity(hass, pywemo_device, wemo_entity_suffix): async def async_wemo_entity_fixture(hass, pywemo_device, wemo_entity_suffix): """Fixture for a Wemo entity in hass.""" return await async_create_wemo_entity(hass, pywemo_device, wemo_entity_suffix) + + +@pytest.fixture(name="wemo_dli_entity") +async def async_wemo_dli_entity_fixture(hass, pywemo_dli_device, wemo_entity_suffix): + """Fixture for a Wemo entity in hass.""" + return await async_create_wemo_entity(hass, pywemo_dli_device, wemo_entity_suffix) diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 9bd3367aeee..a16efb173ae 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -168,6 +168,15 @@ async def test_device_info(hass, wemo_entity): assert device_entries[0].sw_version == MOCK_FIRMWARE_VERSION +async def test_dli_device_info(hass, wemo_dli_entity): + """Verify the DeviceInfo data for Digital Loggers emulated wemo device.""" + dr = device_registry.async_get(hass) + device_entries = list(dr.devices.values()) + + assert device_entries[0].configuration_url == "http://127.0.0.1" + assert device_entries[0].identifiers == {(DOMAIN, "123456789")} + + class TestInsight: """Tests specific to the WeMo Insight device.""" diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index 62920662c6f..93033d984fa 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -150,6 +150,17 @@ FAKE_SOCKET = BulbType( white_channels=2, white_to_color_ratio=80, ) +FAKE_SOCKET_WITH_POWER_MONITORING = BulbType( + bulb_type=BulbClass.SOCKET, + name="ESP25_SOCKET_01", + features=Features( + color=False, color_tmp=False, effect=False, brightness=False, dual_head=False + ), + kelvin_range=KelvinRange(2700, 6500), + fw_version="1.26.2", + white_channels=2, + white_to_color_ratio=80, +) FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType( bulb_type=BulbClass.DW, name=None, @@ -197,7 +208,9 @@ def _mocked_wizlight(device, extended_white_range, bulb_type) -> wizlight: ) bulb.getMac = AsyncMock(return_value=FAKE_MAC) bulb.turn_on = AsyncMock() + bulb.get_power = AsyncMock(return_value=None) bulb.turn_off = AsyncMock() + bulb.power_monitoring = False bulb.updateState = AsyncMock(return_value=FAKE_STATE) bulb.getSupportedScenes = AsyncMock(return_value=list(SCENES.values())) bulb.start_push = AsyncMock(side_effect=_save_setup_callback) diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index f8426ece56d..f37f2ba21a0 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -1,5 +1,5 @@ """Test the WiZ Platform config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError @@ -21,8 +21,10 @@ from . import ( FAKE_SOCKET, TEST_CONNECTION, TEST_SYSTEM_INFO, + _mocked_wizlight, _patch_discovery, _patch_wizlight, + async_setup_integration, ) from tests.common import MockConfigEntry @@ -309,6 +311,35 @@ async def test_discovered_by_dhcp_or_integration_discovery_updates_host( assert entry.data[CONF_HOST] == FAKE_IP +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY), + ], +) +async def test_discovered_by_dhcp_or_integration_discovery_avoid_waiting_for_retry( + hass, source, data +): + """Test dhcp or discovery kicks off setup when in retry.""" + bulb = _mocked_wizlight(None, None, FAKE_SOCKET) + bulb.getMac = AsyncMock(side_effect=OSError) + _, entry = await async_setup_integration(hass, wizlight=bulb) + assert entry.data[CONF_HOST] == FAKE_IP + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + bulb.getMac = AsyncMock(return_value=FAKE_MAC) + + with _patch_wizlight(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.state is config_entries.ConfigEntryState.LOADED + + async def test_setup_via_discovery(hass): """Test setting up via discovery.""" result = await hass.config_entries.flow.async_init( @@ -475,3 +506,34 @@ async def test_discovery_with_firmware_update(hass): } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY), + ], +) +async def test_discovered_during_onboarding(hass, source, data): + """Test dhcp or discovery during onboarding creates the config entry.""" + with _patch_wizlight(), patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.wiz.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "WiZ Dimmable White ABCABC" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/wiz/test_init.py b/tests/components/wiz/test_init.py index 30340f78e49..96ae5e20ba3 100644 --- a/tests/components/wiz/test_init.py +++ b/tests/components/wiz/test_init.py @@ -1,13 +1,16 @@ """Tests for wiz integration.""" import datetime -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from homeassistant import config_entries -from homeassistant.const import ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.components.wiz.const import DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from . import ( + FAKE_IP, FAKE_MAC, FAKE_SOCKET, _mocked_wizlight, @@ -16,7 +19,7 @@ from . import ( async_setup_integration, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_retry(hass: HomeAssistant) -> None: @@ -47,7 +50,17 @@ async def test_cleanup_on_failed_first_update(hass: HomeAssistant) -> None: """Test the socket is cleaned up on failed first update.""" bulb = _mocked_wizlight(None, None, FAKE_SOCKET) bulb.updateState = AsyncMock(side_effect=OSError) - _, entry = await async_setup_integration(hass, wizlight=bulb) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_MAC, + data={CONF_HOST: FAKE_IP}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.wiz.discovery.find_wizlights", return_value=[] + ), _patch_wizlight(device=bulb): + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY bulb.async_close.assert_called_once() diff --git a/tests/components/wiz/test_sensor.py b/tests/components/wiz/test_sensor.py index 37a6b04dad3..a1eb6ded51d 100644 --- a/tests/components/wiz/test_sensor.py +++ b/tests/components/wiz/test_sensor.py @@ -1,11 +1,15 @@ """Tests for the sensor platform.""" +from unittest.mock import AsyncMock + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import ( FAKE_DUAL_HEAD_RGBWW_BULB, FAKE_MAC, + FAKE_SOCKET_WITH_POWER_MONITORING, + _mocked_wizlight, _patch_discovery, _patch_wizlight, async_push_update, @@ -35,3 +39,29 @@ async def test_signal_strength(hass: HomeAssistant) -> None: await async_push_update(hass, bulb, {"mac": FAKE_MAC, "rssi": -50}) assert hass.states.get(entity_id).state == "-50" + + +async def test_power_monitoring(hass: HomeAssistant) -> None: + """Test power monitoring.""" + socket = _mocked_wizlight(None, None, FAKE_SOCKET_WITH_POWER_MONITORING) + socket.power_monitoring = None + socket.get_power = AsyncMock(return_value=5.123) + _, entry = await async_setup_integration( + hass, wizlight=socket, bulb_type=FAKE_SOCKET_WITH_POWER_MONITORING + ) + entity_id = "sensor.mock_title_current_power" + entity_registry = er.async_get(hass) + reg_entry = entity_registry.async_get(entity_id) + assert reg_entry.unique_id == f"{FAKE_MAC}_power" + updated_entity = entity_registry.async_update_entity( + entity_id=entity_id, disabled_by=None + ) + assert not updated_entity.disabled + + with _patch_discovery(), _patch_wizlight(device=socket): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "5.123" + await async_push_update(hass, socket, {"mac": FAKE_MAC, "pc": 800}) + assert hass.states.get(entity_id).state == "0.8" diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index f89d92aaa16..d0b5b24a8fb 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -1,7 +1,7 @@ """Fixtures for WLED integration tests.""" from collections.abc import Generator import json -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from wled import Device as WLEDDevice @@ -25,10 +25,22 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None, AsyncMock, None]: """Mock setting up a config entry.""" - with patch("homeassistant.components.wled.async_setup_entry", return_value=True): - yield + with patch( + "homeassistant.components.wled.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_onboarding() -> Generator[None, MagicMock, None]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding @pytest.fixture diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index c23f35534b8..e1cf08069da 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -1,5 +1,5 @@ """Tests for the WLED config flow.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from wled import WLEDConnectionError @@ -18,7 +18,7 @@ from tests.common import MockConfigEntry async def test_full_user_flow_implementation( - hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: None + hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: AsyncMock ) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -43,7 +43,7 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( - hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: None + hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: AsyncMock ) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -84,6 +84,38 @@ async def test_full_zeroconf_flow_implementation( assert result2["result"].unique_id == "aabbccddeeff" +async def test_zeroconf_during_onboarding( + hass: HomeAssistant, + mock_wled_config_flow: MagicMock, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, +) -> None: + """Test we create a config entry when discovered during onboarding.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + addresses=["192.168.1.123"], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "aabbccddeeff"}, + type="mock_type", + ), + ) + + assert result.get("title") == "WLED RGB Light" + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + + assert result.get("data") == {CONF_HOST: "192.168.1.123"} + assert "result" in result + assert result["result"].unique_id == "aabbccddeeff" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_onboarding.mock_calls) == 1 + + async def test_connection_error( hass: HomeAssistant, mock_wled_config_flow: MagicMock ) -> None: diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 1c19a5e7dfd..7d9acc670b5 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -809,3 +809,53 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" assert config_entry.data[CONF_HOST] == IP_ADDRESS + + +@pytest.mark.parametrize( + "source, data", + [ + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress="aa:bb:cc:dd:ee:ff", hostname="mock_hostname" + ), + ), + ( + config_entries.SOURCE_HOMEKIT, + zeroconf.ZeroconfServiceInfo( + host=IP_ADDRESS, + addresses=[IP_ADDRESS], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), + ), + ], +) +async def test_discovered_during_onboarding(hass, source, data): + """Test we create a config entry when discovered during onboarding.""" + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), patch(f"{MODULE}.async_setup", return_value=True) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ) as mock_is_onboarded: + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + assert mock_is_onboarded.called diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index e3742a3132f..0d3b8ffa9f1 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -22,6 +22,21 @@ from .common import async_enable_traffic, find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +@pytest.fixture(autouse=True) +def alarm_control_panel_platform_only(): + """Only setup the alarm_control_panel and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.ALARM_CONTROL_PANEL, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index dac9855148a..08766bc74ac 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -37,7 +37,7 @@ from homeassistant.components.zha.core.const import ( GROUP_IDS, GROUP_NAME, ) -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import Context from .conftest import ( @@ -53,6 +53,20 @@ IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only setup the required and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ), + ): + yield + + @pytest.fixture async def device_switch(hass, zigpy_device_mock, zha_device_joined): """Test zha switch platform.""" diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index bfe2a3ce4f5..c27c8be16d8 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -1,4 +1,6 @@ """Test zha binary sensor.""" +from unittest.mock import patch + import pytest import zigpy.profiles.zha import zigpy.zcl.clusters.measurement as measurement @@ -34,6 +36,21 @@ DEVICE_OCCUPANCY = { } +@pytest.fixture(autouse=True) +def binary_sensor_platform_only(): + """Only setup the binary_sensor and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + async def async_test_binary_sensor_on_off(hass, cluster, entity_id): """Test getting on and off messages for binary sensors.""" # binary sensor on diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index f692528203f..2fdc263d732 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC, STATE_UNKNOWN, + Platform, ) from homeassistant.helpers import entity_registry as er @@ -37,6 +38,23 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro +@pytest.fixture(autouse=True) +def button_platform_only(): + """Only setup the button and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def contact_sensor(hass, zigpy_device_mock, zha_device_joined_restored): """Contact sensor fixture.""" diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 79b8dbc6a71..7701992cab4 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -20,6 +20,13 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_capture_events +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", []): + yield + + @pytest.fixture def ieee(): """IEEE fixture.""" @@ -510,7 +517,7 @@ async def test_poll_control_cluster_command(hass, poll_control_device): checkin_mock = AsyncMock() poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"] cluster = poll_control_ch.cluster - events = async_capture_events(hass, "zha_event") + events = async_capture_events(hass, zha_const.ZHA_EVENT) with mock.patch.object(poll_control_ch, "check_in_response", checkin_mock): tsn = 22 diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 7866bba076c..a04b2c116e3 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -171,6 +171,24 @@ ZCL_ATTR_PLUG = { } +@pytest.fixture(autouse=True) +def climate_platform_only(): + """Only setup the climate and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.CLIMATE, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ), + ): + yield + + @pytest.fixture def device_climate_mock(hass, zigpy_device_mock, zha_device_joined): """Test regular thermostat device.""" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index dee04165c1e..285c08d8a3e 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -34,6 +34,13 @@ from homeassistant.data_entry_flow import ( from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", []): + yield + + def com_port(): """Mock of a serial port.""" port = serial.tools.list_ports_common.ListPortInfo("/dev/ttyUSB1234") @@ -766,3 +773,107 @@ async def test_migration_ti_cc_to_znp(old_type, new_type, hass, config_entry): assert config_entry.version > 2 assert config_entry.data[CONF_RADIO_TYPE] == new_type + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_not_onboarded(hass): + """Test hardware flow.""" + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "/dev/ttyAMA1" + assert result["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_onboarded(hass): + """Test hardware flow.""" + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm_hardware" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "/dev/ttyAMA1" + assert result["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +async def test_hardware_already_setup(hass): + """Test hardware flow -- already setup.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.parametrize( + "data", (None, {}, {"radio_type": "best_radio"}, {"radio_type": "efr32"}) +) +async def test_hardware_invalid_data(hass, data): + """Test onboarding flow -- invalid data.""" + + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_hardware_data" diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 3c00b5d3109..3dab405151d 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -17,6 +17,7 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, ) +from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( ATTR_COMMAND, STATE_CLOSED, @@ -38,6 +39,21 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_capture_events, mock_coro, mock_restore_cache +@pytest.fixture(autouse=True) +def cover_platform_only(): + """Only setup the cover and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture def zigpy_cover_device(zigpy_device_mock): """Zigpy cover device.""" @@ -410,7 +426,7 @@ async def test_cover_remote(hass, zha_device_joined_restored, zigpy_cover_remote cluster = zigpy_cover_remote.endpoints[1].out_clusters[ closures.WindowCovering.cluster_id ] - zha_events = async_capture_events(hass, "zha_event") + zha_events = async_capture_events(hass, ZHA_EVENT) # up command hdr = make_zcl_header(0, global_command=False) diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index 0f2caceada5..8b718635b6a 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -12,7 +12,7 @@ from homeassistant.components.zha.core.const import ( CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, ) -from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform import homeassistant.helpers.device_registry as dr import homeassistant.util.dt as dt_util @@ -22,6 +22,22 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_fire_time_changed +@pytest.fixture(autouse=True) +def required_platforms_only(): + """Only setup the required platform and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + Platform.BINARY_SENSOR, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 54cc7e9171d..fffb79fe0f2 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -24,6 +24,23 @@ COMMAND = "command" COMMAND_SINGLE = "single" +@pytest.fixture(autouse=True) +def required_platforms_only(): + """Only setup the required platforms and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + ), + ): + yield + + @pytest.fixture async def device_ias(hass, zigpy_device_mock, zha_device_joined_restored): """IAS device fixture.""" diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 06caac91cb2..59c36143a6e 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -1,6 +1,7 @@ """Test ZHA Device Tracker.""" from datetime import timedelta import time +from unittest.mock import patch import pytest import zigpy.profiles.zha @@ -24,6 +25,23 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_fire_time_changed +@pytest.fixture(autouse=True) +def device_tracker_platforms_only(): + """Only setup the device_tracker platforms and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.BUTTON, + Platform.SELECT, + Platform.NUMBER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture def zigpy_device_dt(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index ac93cbe0c7f..1f5fa467a93 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -1,6 +1,7 @@ """ZHA device automation trigger tests.""" from datetime import timedelta import time +from unittest.mock import patch import pytest import zigpy.profiles.zha @@ -8,6 +9,7 @@ import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -36,6 +38,13 @@ LONG_PRESS = "remote_button_long_press" LONG_RELEASE = "remote_button_long_release" +@pytest.fixture(autouse=True) +def sensor_platforms_only(): + """Only setup the sensor platform and required base platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", (Platform.SENSOR,)): + yield + + def _same_lists(list_a, list_b): if len(list_a) != len(list_b): return False diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 804b6d73316..d88996c78f1 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from unittest.mock import patch + import pytest import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security @@ -8,6 +10,7 @@ import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics.const import REDACTED from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import async_get @@ -26,6 +29,15 @@ CONFIG_ENTRY_DIAGNOSTICS_KEYS = [ ] +@pytest.fixture(autouse=True) +def required_platforms_only(): + """Only setup the required platform and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", (Platform.ALARM_CONTROL_PANEL,) + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 19f5f39a22f..9ebc5ae1c79 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -2,10 +2,10 @@ from unittest.mock import AsyncMock, call, patch import pytest +import zhaquirks.ikea.starkvind from zigpy.exceptions import ZigbeeException -import zigpy.profiles.zha as zha -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.hvac as hvac +from zigpy.profiles import zha +from zigpy.zcl.clusters import general, hvac import zigpy.zcl.foundation as zcl_f from homeassistant.components.fan import ( @@ -51,6 +51,26 @@ IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" +@pytest.fixture(autouse=True) +def fan_platform_only(): + """Only setup the fan and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.LIGHT, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" @@ -500,3 +520,179 @@ async def test_fan_update_entity( assert cluster.read_attributes.await_count == 4 else: assert cluster.read_attributes.await_count == 6 + + +@pytest.fixture +def zigpy_device_ikea(zigpy_device_mock): + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="IKEA of Sweden", + model="STARKVIND Air purifier", + quirk=zhaquirks.ikea.starkvind.IkeaSTARKVIND, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_ikea(hass, zha_device_joined_restored, zigpy_device_ikea): + """Test zha fan Ikea platform.""" + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the fan was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on at fan + await send_attributes_report(hass, cluster, {6: 1}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at fan + await send_attributes_report(hass, cluster, {6: 0}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(hass, entity_id) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(hass, entity_id) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 0}) + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(hass, entity_id, percentage=100) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 10}) + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode( + hass, entity_id, preset_mode="invalid does not exist" + ) + assert len(cluster.write_attributes.mock_calls) == 0 + + # test adding new fan to the network and HA + await async_test_rejoin(hass, zigpy_device_ikea, [cluster], (9,)) + + +@pytest.mark.parametrize( + "ikea_plug_read, ikea_expected_state, ikea_expected_percentage, ikea_preset_mode", + ( + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, 0, None), + ({"fan_mode": 1}, STATE_ON, 10, PRESET_MODE_AUTO), + ({"fan_mode": 10}, STATE_ON, 20, "Speed 1"), + ({"fan_mode": 15}, STATE_ON, 30, "Speed 1.5"), + ({"fan_mode": 20}, STATE_ON, 40, "Speed 2"), + ({"fan_mode": 25}, STATE_ON, 50, "Speed 2.5"), + ({"fan_mode": 30}, STATE_ON, 60, "Speed 3"), + ({"fan_mode": 35}, STATE_ON, 70, "Speed 3.5"), + ({"fan_mode": 40}, STATE_ON, 80, "Speed 4"), + ({"fan_mode": 45}, STATE_ON, 90, "Speed 4.5"), + ({"fan_mode": 50}, STATE_ON, 100, "Speed 5"), + ), +) +async def test_fan_ikea_init( + hass, + zha_device_joined_restored, + zigpy_device_ikea, + ikea_plug_read, + ikea_expected_state, + ikea_expected_percentage, + ikea_preset_mode, +): + """Test zha fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = ikea_plug_read + + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == ikea_expected_state + assert ( + hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] + == ikea_expected_percentage + ) + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == ikea_preset_mode + + +async def test_fan_ikea_update_entity( + hass, + zha_device_joined_restored, + zigpy_device_ikea, +): + """Test zha fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 10 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 3 + else: + assert cluster.read_attributes.await_count == 6 + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 4 + else: + assert cluster.read_attributes.await_count == 7 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 10 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is PRESET_MODE_AUTO + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 10 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 5 + else: + assert cluster.read_attributes.await_count == 8 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 67ce6481542..b19c98548ce 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -35,6 +35,22 @@ def zigpy_dev_basic(zigpy_device_mock): ) +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only setup the required and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.SENSOR, + Platform.LIGHT, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture async def zha_dev_basic(hass, zha_device_restored, zigpy_dev_basic): """ZHA device with just a basic cluster.""" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 0615eeef623..262a743559f 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -20,6 +20,13 @@ DATA_RADIO_TYPE = "deconz" DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0" +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", []): + yield + + @pytest.fixture def config_entry_v1(hass): """Config entry version 1 fixture.""" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 4ac777f5d8e..dd6df0dff19 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, FLASH_LONG, FLASH_SHORT, + ColorMode, ) from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.light import FLASH_EFFECTS @@ -79,6 +80,24 @@ LIGHT_COLOR = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.BUTTON, + Platform.LIGHT, + Platform.SENSOR, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture async def coordinator(hass, zigpy_device_mock, zha_device_joined): """Test zha light platform.""" @@ -580,7 +599,11 @@ async def test_zha_group_light_entity( await async_wait_for_updates(hass) # test that the lights were created and are off - assert hass.states.get(group_entity_id).state == STATE_OFF + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + # Light which is off has no color mode + assert "color_mode" not in group_state.attributes # test turning the lights on and off from the HA await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) @@ -603,6 +626,11 @@ async def test_zha_group_light_entity( await async_test_dimmer_from_light( hass, dev1_cluster_level, group_entity_id, 150, STATE_ON ) + # Check state + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_ON + assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + assert group_state.attributes["color_mode"] == ColorMode.HS # test long flashing the lights from the HA await async_test_flash_from_hass( diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index 0669cebf128..08b720b2ad7 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -27,6 +27,20 @@ CLEAR_PIN_CODE = 7 SET_USER_STATUS = 9 +@pytest.fixture(autouse=True) +def lock_platform_only(): + """Only setup the lock and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.LOCK, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def lock(hass, zigpy_device_mock, zha_device_joined_restored): """Lock cluster fixture.""" diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py new file mode 100644 index 00000000000..373a48c2d47 --- /dev/null +++ b/tests/components/zha/test_logbook.py @@ -0,0 +1,265 @@ +"""ZHA logbook describe events tests.""" + +from unittest.mock import patch + +import pytest +import zigpy.profiles.zha +import zigpy.zcl.clusters.general as general + +from homeassistant.components.zha.core.const import ZHA_EVENT +from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID, Platform +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +from tests.components.logbook.common import MockRow, mock_humanify + +ON = 1 +OFF = 0 +SHAKEN = "device_shaken" +COMMAND = "command" +COMMAND_SHAKE = "shake" +COMMAND_HOLD = "hold" +COMMAND_SINGLE = "single" +COMMAND_DOUBLE = "double" +DOUBLE_PRESS = "remote_button_double_press" +SHORT_PRESS = "remote_button_short_press" +LONG_PRESS = "remote_button_long_press" +LONG_RELEASE = "remote_button_long_release" +UP = "up" +DOWN = "down" + + +@pytest.fixture(autouse=True) +def sensor_platform_only(): + """Only setup the sensor and required base platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", (Platform.SENSOR,)): + yield + + +@pytest.fixture +async def mock_devices(hass, zigpy_device_mock, zha_device_joined): + """IAS device fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + ) + + zha_device = await zha_device_joined(zigpy_device) + zha_device.update_available(True) + await hass.async_block_till_done() + return zigpy_device, zha_device + + +async def test_zha_logbook_event_device_with_triggers(hass, mock_devices): + """Test zha logbook events with device and triggers.""" + + zigpy_device, zha_device = mock_devices + + zigpy_device.device_automation_triggers = { + (SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE}, + (UP, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE, "endpoint_id": 1}, + (DOWN, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE, "endpoint_id": 2}, + (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}, + (LONG_PRESS, LONG_PRESS): {COMMAND: COMMAND_HOLD}, + (LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD}, + } + + ieee_address = str(zha_device.ieee) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_SHAKE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_DOUBLE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_DOUBLE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 2, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "FakeManufacturer FakeModel" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Device Shaken event was fired with parameters: {'test': 'test'}" + ) + + assert events[1]["name"] == "FakeManufacturer FakeModel" + assert events[1]["domain"] == "zha" + assert ( + events[1]["message"] + == "Up - Remote Button Double Press event was fired with parameters: {'test': 'test'}" + ) + + +async def test_zha_logbook_event_device_no_triggers(hass, mock_devices): + """Test zha logbook events with device and without triggers.""" + + zigpy_device, zha_device = mock_devices + ieee_address = str(zha_device.ieee) + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_SHAKE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": {}, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + }, + ), + ], + ) + + assert events[0]["name"] == "FakeManufacturer FakeModel" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Shake event was fired with parameters: {'test': 'test'}" + ) + + assert events[1]["name"] == "FakeManufacturer FakeModel" + assert events[1]["domain"] == "zha" + assert ( + events[1]["message"] == "Zha Event was fired with parameters: {'test': 'test'}" + ) + + assert events[2]["name"] == "FakeManufacturer FakeModel" + assert events[2]["domain"] == "zha" + assert events[2]["message"] == "Zha Event was fired" + + assert events[3]["name"] == "FakeManufacturer FakeModel" + assert events[3]["domain"] == "zha" + assert events[3]["message"] == "Zha Event was fired" + + +async def test_zha_logbook_event_device_no_device(hass, mock_devices): + """Test zha logbook events without device and without triggers.""" + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: "non-existing-device", + COMMAND: COMMAND_SHAKE, + "device_ieee": "90:fd:9f:ff:fe:fe:d8:a1", + CONF_UNIQUE_ID: "90:fd:9f:ff:fe:fe:d8:a1:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "Unknown device" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Shake event was fired with parameters: {'test': 'test'}" + ) diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 01946c05f1a..f6b606ccbbf 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -24,6 +24,23 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import mock_coro +@pytest.fixture(autouse=True) +def number_platform_only(): + """Only setup the number and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture def zigpy_analog_output_device(zigpy_device_mock): """Zigpy analog_output device.""" diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index 70b943d5ea2..c883d648e8e 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -1,6 +1,6 @@ """Test ZHA select entities.""" -from unittest.mock import call +from unittest.mock import call, patch import pytest from zigpy.const import SIG_EP_PROFILE @@ -16,6 +16,24 @@ from .common import find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +@pytest.fixture(autouse=True) +def select_select_only(): + """Only setup the select and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SIREN, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def siren(hass, zigpy_device_mock, zha_device_joined_restored): """Siren fixture.""" diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 0d476fb8bda..c638bdd8c48 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,5 +1,6 @@ """Test zha sensor.""" import math +from unittest.mock import patch import pytest import zigpy.profiles.zha @@ -50,6 +51,19 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_e769900a_{}" +@pytest.fixture(autouse=True) +def sensor_platform_only(): + """Only setup the sensor and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def elec_measurement_zigpy_dev(hass, zigpy_device_mock): """Electric Measurement zigpy device.""" diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 285bc1cd585..72a40a8323e 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -28,6 +28,22 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_fire_time_changed, mock_coro +@pytest.fixture(autouse=True) +def siren_platform_only(): + """Only setup the siren and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SENSOR, + Platform.SELECT, + Platform.SIREN, + ), + ): + yield + + @pytest.fixture async def siren(hass, zigpy_device_mock, zha_device_joined_restored): """Siren fixture.""" diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 99e8a681348..0b8fe658c28 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -41,6 +41,21 @@ IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" +@pytest.fixture(autouse=True) +def switch_platform_only(): + """Only setup the switch and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e2ab9cc9503..3a2bff54e88 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -198,14 +198,14 @@ async def test_network_status(hass, multisensor_6, integration, hass_ws_client): assert msg["error"]["code"] == ERR_INVALID_FORMAT -async def test_node_ready( +async def test_subscribe_node_status( hass, multisensor_6_state, client, integration, hass_ws_client, ): - """Test the node ready websocket command.""" + """Test the subscribe node status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. @@ -222,7 +222,7 @@ async def test_node_ready( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/node_ready", + TYPE: "zwave_js/subscribe_node_status", DEVICE_ID: device.id, } ) @@ -246,6 +246,25 @@ async def test_node_ready( msg = await ws_client.receive_json() assert msg["event"]["event"] == "ready" + assert msg["event"]["status"] == 1 + assert msg["event"]["ready"] + + event = Event( + "wake up", + { + "source": "node", + "event": "wake up", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + msg = await ws_client.receive_json() + + assert msg["event"]["event"] == "wake up" + assert msg["event"]["status"] == 2 + assert msg["event"]["ready"] async def test_node_status(hass, multisensor_6, integration, hass_ws_client): @@ -1569,7 +1588,7 @@ async def test_replace_failed_node( dev_reg = dr.async_get(hass) # Create device registry entry for mock node - dev_reg.async_get_or_create( + device = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "3245146787-67")}, name="Node 67", @@ -1584,8 +1603,7 @@ async def test_replace_failed_node( { ID: 1, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, } ) @@ -1665,10 +1683,12 @@ async def test_replace_failed_node( assert msg["event"]["event"] == "node removed" # Verify device was removed from device registry - device = dev_reg.async_get_device( - identifiers={(DOMAIN, "3245146787-67")}, + assert ( + dev_reg.async_get_device( + identifiers={(DOMAIN, "3245146787-67")}, + ) + is None ) - assert device is None client.driver.receive_event(nortek_thermostat_added_event) msg = await ws_client.receive_json() @@ -1744,8 +1764,7 @@ async def test_replace_failed_node( { ID: 2, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, PLANNED_PROVISIONING_ENTRY: { DSK: "test", @@ -1777,8 +1796,7 @@ async def test_replace_failed_node( { ID: 3, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, QR_PROVISIONING_INFORMATION: { VERSION: 0, @@ -1830,8 +1848,7 @@ async def test_replace_failed_node( { ID: 4, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", } @@ -1858,8 +1875,7 @@ async def test_replace_failed_node( { ID: 6, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", } @@ -1879,8 +1895,7 @@ async def test_replace_failed_node( { ID: 7, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, } ) msg = await ws_client.receive_json() @@ -1897,8 +1912,7 @@ async def test_replace_failed_node( { ID: 8, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, } ) msg = await ws_client.receive_json() @@ -2833,9 +2847,10 @@ async def test_firmware_upload_view( ) as mock_cmd: resp = await client.post( f"/api/zwave_js/firmware/upload/{device.id}", - data={"file": firmware_file}, + data={"file": firmware_file, "target": "15"}, ) assert mock_cmd.call_args[0][1:4] == (multisensor_6, "file", bytes(10)) + assert mock_cmd.call_args[1] == {"target": 15} assert json.loads(await resp.text()) is None @@ -3358,7 +3373,7 @@ async def test_abort_firmware_update( ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) - client.async_send_command_no_wait.return_value = {} + client.async_send_command.return_value = {} await ws_client.send_json( { ID: 1, @@ -3369,8 +3384,8 @@ async def test_abort_firmware_update( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command_no_wait.call_args_list) == 1 - args = client.async_send_command_no_wait.call_args[0][0] + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] assert args["command"] == "node.abort_firmware_update" assert args["nodeId"] == multisensor_6.node_id @@ -3408,19 +3423,10 @@ async def test_abort_firmware_update( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_LOADED - -async def test_abort_firmware_update_failures( - hass, multisensor_6, client, integration, hass_ws_client -): - """Test failures for the abort_firmware_update websocket command.""" - entry = integration - ws_client = await hass_ws_client(hass) - device = get_device(hass, multisensor_6) - # Test sending command with improper device ID fails await ws_client.send_json( { - ID: 2, + ID: 4, TYPE: "zwave_js/abort_firmware_update", DEVICE_ID: "fake_device", } @@ -3430,6 +3436,50 @@ async def test_abort_firmware_update_failures( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + +async def test_get_firmware_update_progress( + hass, client, multisensor_6, integration, hass_ws_client +): + """Test that the get_firmware_update_progress WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + client.async_send_command.return_value = {"progress": True} + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_firmware_update_progress", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.get_firmware_update_progress" + assert args["nodeId"] == multisensor_6.node_id + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_get_firmware_update_progress", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/get_firmware_update_progress", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -3437,7 +3487,7 @@ async def test_abort_firmware_update_failures( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/abort_firmware_update", + TYPE: "zwave_js/get_firmware_update_progress", DEVICE_ID: device.id, } ) @@ -3588,6 +3638,162 @@ async def test_subscribe_firmware_update_status_failures( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_get_firmware_update_capabilities( + hass, client, multisensor_6, integration, hass_ws_client +): + """Test that the get_firmware_update_capabilities WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + client.async_send_command.return_value = { + "capabilities": { + "firmwareUpgradable": True, + "firmwareTargets": [0], + "continuesToFunction": True, + "supportsActivation": True, + } + } + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == { + "firmware_upgradable": True, + "firmware_targets": [0], + "continues_to_function": True, + "supports_activation": True, + } + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.get_firmware_update_capabilities" + assert args["nodeId"] == multisensor_6.node_id + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_get_firmware_update_capabilities", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + # Test sending command with improper device ID fails + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: "fake_device", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_get_any_firmware_update_progress( + hass, client, integration, hass_ws_client +): + """Test that the get_any_firmware_update_progress WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"progress": True} + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "controller.get_any_firmware_update_progress" + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_get_any_firmware_update_progress", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + # Test sending command with improper device ID fails + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: "invalid_entry", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + async def test_check_for_config_updates(hass, client, integration, hass_ws_client): """Test that the check_for_config_updates WS API call works.""" entry = integration @@ -3826,18 +4032,26 @@ async def test_subscribe_controller_statistics( async def test_subscribe_node_statistics( - hass, multisensor_6, integration, client, hass_ws_client + hass, + multisensor_6, + wallmote_central_scene, + zen_31, + integration, + client, + hass_ws_client, ): """Test the subscribe_node_statistics command.""" entry = integration ws_client = await hass_ws_client(hass) - device = get_device(hass, multisensor_6) + multisensor_6_device = get_device(hass, multisensor_6) + zen_31_device = get_device(hass, zen_31) + wallmote_central_scene_device = get_device(hass, wallmote_central_scene) await ws_client.send_json( { ID: 1, TYPE: "zwave_js/subscribe_node_statistics", - DEVICE_ID: device.id, + DEVICE_ID: multisensor_6_device.id, } ) @@ -3855,6 +4069,10 @@ async def test_subscribe_node_statistics( "commands_dropped_tx": 0, "commands_dropped_rx": 0, "timeout_response": 0, + "rtt": None, + "rssi": None, + "lwr": None, + "nlwr": None, } # Fire statistics updated @@ -3866,10 +4084,32 @@ async def test_subscribe_node_statistics( "nodeId": multisensor_6.node_id, "statistics": { "commandsTX": 1, - "commandsRX": 1, - "commandsDroppedTX": 1, - "commandsDroppedRX": 1, - "timeoutResponse": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [wallmote_central_scene.node_id], + "repeaterRSSI": [1], + "routeFailedBetween": [ + zen_31.node_id, + multisensor_6.node_id, + ], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [127], + "routeFailedBetween": [ + multisensor_6.node_id, + zen_31.node_id, + ], + }, }, }, ) @@ -3880,10 +4120,32 @@ async def test_subscribe_node_statistics( "source": "node", "node_id": multisensor_6.node_id, "commands_tx": 1, - "commands_rx": 1, - "commands_dropped_tx": 1, - "commands_dropped_rx": 1, - "timeout_response": 1, + "commands_rx": 2, + "commands_dropped_tx": 3, + "commands_dropped_rx": 4, + "timeout_response": 5, + "rtt": 6, + "rssi": 7, + "lwr": { + "protocol_data_rate": 1, + "rssi": 1, + "repeaters": [wallmote_central_scene_device.id], + "repeater_rssi": [1], + "route_failed_between": [ + zen_31_device.id, + multisensor_6_device.id, + ], + }, + "nlwr": { + "protocol_data_rate": 2, + "rssi": 2, + "repeaters": [], + "repeater_rssi": [127], + "route_failed_between": [ + multisensor_6_device.id, + zen_31_device.id, + ], + }, } # Test sending command with improper entry ID fails @@ -3907,7 +4169,7 @@ async def test_subscribe_node_statistics( { ID: 4, TYPE: "zwave_js/subscribe_node_statistics", - DEVICE_ID: device.id, + DEVICE_ID: multisensor_6_device.id, } ) msg = await ws_client.receive_json() diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 78b28a8ef10..979aa8bf088 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -139,6 +139,30 @@ async def test_discovery_confirmation(hass, discovery_flow_conf, source): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY +@pytest.mark.parametrize( + "source", + [ + config_entries.SOURCE_DISCOVERY, + config_entries.SOURCE_MQTT, + config_entries.SOURCE_SSDP, + config_entries.SOURCE_ZEROCONF, + config_entries.SOURCE_DHCP, + ], +) +async def test_discovery_during_onboarding(hass, discovery_flow_conf, source): + """Test we create config entry via discovery during onboarding.""" + flow = config_entries.HANDLERS["test"]() + flow.hass = hass + flow.context = {"source": source} + + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await getattr(flow, f"async_step_{source}")({}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + async def test_multiple_discoveries(hass, discovery_flow_conf): """Test we only create one instance for multiple discoveries.""" mock_entity_platform(hass, "config_flow.test", None) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index e345d7d7258..b9067a3db1c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -12,17 +12,18 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, - TEMP_FAHRENHEIT, ) from homeassistant.core import Context, HomeAssistantError -from homeassistant.helpers import entity, entity_registry +from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockPlatform, get_test_home_assistant, mock_registry, ) @@ -595,11 +596,11 @@ async def test_set_context_expired(hass): async def test_warn_disabled(hass, caplog): """Test we warn once if we write to a disabled entity.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", - disabled_by=entity_registry.RegistryEntryDisabler.USER, + disabled_by=er.RegistryEntryDisabler.USER, ) mock_registry(hass, {"hello.world": entry}) @@ -622,7 +623,7 @@ async def test_warn_disabled(hass, caplog): async def test_disabled_in_entity_registry(hass): """Test entity is removed if we disable entity registry entry.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -641,7 +642,7 @@ async def test_disabled_in_entity_registry(hass): assert hass.states.get("hello.world") is not None entry2 = registry.async_update_entity( - "hello.world", disabled_by=entity_registry.RegistryEntryDisabler.USER + "hello.world", disabled_by=er.RegistryEntryDisabler.USER ) await hass.async_block_till_done() assert entry2 != entry @@ -750,7 +751,7 @@ async def test_setup_source(hass): async def test_removing_entity_unavailable(hass): """Test removing an entity that is still registered creates an unavailable state.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -816,64 +817,6 @@ async def test_float_conversion(hass): assert state.state == "3.6" -async def test_temperature_conversion(hass, caplog): - """Test conversion of temperatures.""" - # Non sensor entity reporting a temperature - with patch.object( - entity.Entity, "state", PropertyMock(return_value=100) - ), patch.object( - entity.Entity, "unit_of_measurement", PropertyMock(return_value=TEMP_FAHRENHEIT) - ): - ent = entity.Entity() - ent.hass = hass - ent.entity_id = "hello.world" - ent.async_write_ha_state() - - state = hass.states.get("hello.world") - assert state is not None - assert state.state == "38" - assert ( - "Entity hello.world () relies on automatic " - "temperature conversion, this will be unsupported in Home Assistant Core 2022.7. " - "Please create a bug report" in caplog.text - ) - - # Sensor entity, not extending SensorEntity, reporting a temperature - with patch.object( - entity.Entity, "state", PropertyMock(return_value=100) - ), patch.object( - entity.Entity, "unit_of_measurement", PropertyMock(return_value=TEMP_FAHRENHEIT) - ): - ent = entity.Entity() - ent.hass = hass - ent.entity_id = "sensor.temp" - ent.async_write_ha_state() - - state = hass.states.get("sensor.temp") - assert state is not None - assert state.state == "38" - assert ( - "Temperature sensor sensor.temp () " - "does not inherit SensorEntity, this will be unsupported in Home Assistant Core " - "2022.7.Please create a bug report" in caplog.text - ) - - # Sensor entity, not extending SensorEntity, not reporting a number - with patch.object( - entity.Entity, "state", PropertyMock(return_value="really warm") - ), patch.object( - entity.Entity, "unit_of_measurement", PropertyMock(return_value=TEMP_FAHRENHEIT) - ): - ent = entity.Entity() - ent.hass = hass - ent.entity_id = "sensor.temp" - ent.async_write_ha_state() - - state = hass.states.get("sensor.temp") - assert state is not None - assert state.state == "really warm" - - async def test_attribution_attribute(hass): """Test attribution attribute.""" mock_entity = entity.Entity() @@ -945,3 +888,49 @@ async def test_entity_description_fallback(): continue assert getattr(ent, field.name) == getattr(ent_with_description, field.name) + + +@pytest.mark.parametrize( + "has_entity_name, entity_name, expected_friendly_name", + ( + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + ), +) +async def test_friendly_name( + hass, has_entity_name, entity_name, expected_friendly_name +): + """Test entity_id is influenced by entity name.""" + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ), + ] + ) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 933669ebc53..80a37f9f2fd 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1392,3 +1392,49 @@ class SlowEntity(MockEntity): """Make sure control is returned to the event loop on add.""" await asyncio.sleep(0.1) await super().async_added_to_hass() + + +@pytest.mark.parametrize( + "has_entity_name, entity_name, expected_entity_id", + ( + (False, "Entity Blu", "test_domain.entity_blu"), + (False, None, "test_domain.test_qwer"), # Set to _ + (True, "Entity Blu", "test_domain.device_bla_entity_blu"), + (True, None, "test_domain.device_bla"), + ), +) +async def test_entity_name_influences_entity_id( + hass, has_entity_name, entity_name, expected_entity_id +): + """Test entity_id is influenced by entity name.""" + registry = er.async_get(hass) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ), + ] + ) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + assert registry.async_get(expected_entity_id) is not None diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 21d29736bd0..ba69a98d5a8 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -79,6 +79,8 @@ def test_get_or_create_updates_data(registry): device_id="mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, + has_entity_name=True, original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", @@ -97,8 +99,10 @@ def test_get_or_create_updates_data(registry): device_id="mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, icon=None, id=orig_entry.id, + has_entity_name=True, name=None, original_device_class="mock-device-class", original_icon="initial-original_icon", @@ -119,6 +123,8 @@ def test_get_or_create_updates_data(registry): device_id="new-mock-dev-id", disabled_by=er.RegistryEntryDisabler.USER, entity_category=None, + hidden_by=er.RegistryEntryHider.USER, + has_entity_name=False, original_device_class="new-mock-device-class", original_icon="updated-original_icon", original_name="updated-original_name", @@ -137,8 +143,10 @@ def test_get_or_create_updates_data(registry): device_id="new-mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, # Should not be updated entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, # Should not be updated icon=None, id=orig_entry.id, + has_entity_name=False, name=None, original_device_class="new-mock-device-class", original_icon="updated-original_icon", @@ -191,6 +199,8 @@ async def test_loading_saving_data(hass, registry): device_id="mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, + has_entity_name=True, original_device_class="mock-device-class", original_icon="hass:original-icon", original_name="Original Name", @@ -231,6 +241,8 @@ async def test_loading_saving_data(hass, registry): assert new_entry2.disabled_by is er.RegistryEntryDisabler.HASS assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" + assert new_entry2.hidden_by == er.RegistryEntryHider.INTEGRATION + assert new_entry2.has_entity_name is True assert new_entry2.name == "User Name" assert new_entry2.options == {"light": {"minimum_brightness": 20}} assert new_entry2.original_device_class == "mock-device-class" @@ -261,8 +273,8 @@ def test_is_registered(registry): @pytest.mark.parametrize("load_registries", [False]) -async def test_loading_extra_values(hass, hass_storage): - """Test we load extra data from the registry.""" +async def test_filter_on_load(hass, hass_storage): + """Test we transform some data when loading from storage.""" hass_storage[er.STORAGE_KEY] = { "version": er.STORAGE_VERSION_MAJOR, "minor_version": 1, @@ -274,6 +286,7 @@ async def test_loading_extra_values(hass, hass_storage): "unique_id": "with-name", "name": "registry override", }, + # This entity's name should be None { "entity_id": "test.no_name", "platform": "super_platform", @@ -283,20 +296,22 @@ async def test_loading_extra_values(hass, hass_storage): "entity_id": "test.disabled_user", "platform": "super_platform", "unique_id": "disabled-user", - "disabled_by": er.RegistryEntryDisabler.USER, + "disabled_by": "user", # We store the string representation }, { "entity_id": "test.disabled_hass", "platform": "super_platform", "unique_id": "disabled-hass", - "disabled_by": er.RegistryEntryDisabler.HASS, + "disabled_by": "hass", # We store the string representation }, + # This entry should not be loaded because the entity_id is invalid { "entity_id": "test.invalid__entity", "platform": "super_platform", "unique_id": "invalid-hass", - "disabled_by": er.RegistryEntryDisabler.HASS, + "disabled_by": "hass", # We store the string representation }, + # This entry should have the entity_category reset to None { "entity_id": "test.system_entity", "platform": "super_platform", @@ -311,6 +326,13 @@ async def test_loading_extra_values(hass, hass_storage): registry = er.async_get(hass) assert len(registry.entities) == 5 + assert set(registry.entities.keys()) == { + "test.disabled_hass", + "test.disabled_user", + "test.named", + "test.no_name", + "test.system_entity", + } entry_with_name = registry.async_get_or_create( "test", "super_platform", "with-name" @@ -1221,7 +1243,7 @@ def test_entity_registry_items(): async def test_disabled_by_str_not_allowed(hass): - """Test we need to pass entity category type.""" + """Test we need to pass disabled by type.""" reg = er.async_get(hass) with pytest.raises(ValueError): @@ -1252,6 +1274,20 @@ async def test_entity_category_str_not_allowed(hass): ) +async def test_hidden_by_str_not_allowed(hass): + """Test we need to pass hidden by type.""" + reg = er.async_get(hass) + + with pytest.raises(ValueError): + reg.async_get_or_create( + "light", "hue", "1234", hidden_by=er.RegistryEntryHider.USER.value + ) + + entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + reg.async_update_entity(entity_id, hidden_by=er.RegistryEntryHider.USER.value) + + def test_migrate_entity_to_new_platform(hass, registry): """Test migrate_entity_to_new_platform.""" orig_config_entry = MockConfigEntry(domain="light") diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 9576c7d95b6..f9d7ca47b4c 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -91,8 +91,8 @@ def test_excludes_only_with_glob_case_3(): assert testfilter("cover.garage_door") -def test_with_include_domain_case4a(): - """Test case 4a - include and exclude specified, with included domain.""" +def test_with_include_domain_case4(): + """Test case 4 - include and exclude specified, with included domain.""" incl_dom = {"light", "sensor"} incl_ent = {"binary_sensor.working"} excl_dom = {} @@ -108,8 +108,30 @@ def test_with_include_domain_case4a(): assert testfilter("sun.sun") is False -def test_with_include_glob_case4a(): - """Test case 4a - include and exclude specified, with included glob.""" +def test_with_include_domain_exclude_glob_case4(): + """Test case 4 - include and exclude specified, with included domain but excluded by glob.""" + incl_dom = {"light", "sensor"} + incl_ent = {"binary_sensor.working"} + incl_glob = {} + excl_dom = {} + excl_ent = {"light.ignoreme", "sensor.notworking"} + excl_glob = {"sensor.busted"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.test") + assert testfilter("sensor.busted") is False + assert testfilter("sensor.notworking") is False + assert testfilter("light.test") + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.another") is False + assert testfilter("sun.sun") is False + + +def test_with_include_glob_case4(): + """Test case 4 - include and exclude specified, with included glob.""" incl_dom = {} incl_glob = {"light.*", "sensor.*"} incl_ent = {"binary_sensor.working"} @@ -129,8 +151,8 @@ def test_with_include_glob_case4a(): assert testfilter("sun.sun") is False -def test_with_include_domain_glob_filtering_case4a(): - """Test case 4a - include and exclude specified, both have domains and globs.""" +def test_with_include_domain_glob_filtering_case4(): + """Test case 4 - include and exclude specified, both have domains and globs.""" incl_dom = {"light"} incl_glob = {"*working"} incl_ent = {} @@ -142,17 +164,64 @@ def test_with_include_domain_glob_filtering_case4a(): ) assert testfilter("sensor.working") - assert testfilter("sensor.notworking") is False + assert testfilter("sensor.notworking") is True # include is stronger assert testfilter("light.test") - assert testfilter("light.notworking") is False + assert testfilter("light.notworking") is True # include is stronger assert testfilter("light.ignoreme") is False - assert testfilter("binary_sensor.not_working") is False + assert testfilter("binary_sensor.not_working") is True # include is stronger assert testfilter("binary_sensor.another") is False assert testfilter("sun.sun") is False -def test_exclude_domain_case4b(): - """Test case 4b - include and exclude specified, with excluded domain.""" +def test_with_include_domain_glob_filtering_case4a_include_strong(): + """Test case 4 - include and exclude specified, both have domains and globs, and a specifically included entity.""" + incl_dom = {"light"} + incl_glob = {"*working"} + incl_ent = {"binary_sensor.specificly_included"} + excl_dom = {"binary_sensor"} + excl_glob = {"*notworking"} + excl_ent = {"light.ignoreme"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.working") + assert testfilter("sensor.notworking") is True # iclude is stronger + assert testfilter("light.test") + assert testfilter("light.notworking") is True # iclude is stronger + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.not_working") is True # iclude is stronger + assert testfilter("binary_sensor.another") is False + assert testfilter("binary_sensor.specificly_included") is True + assert testfilter("sun.sun") is False + + +def test_with_include_glob_filtering_case4a_include_strong(): + """Test case 4 - include and exclude specified, both have globs, and a specifically included entity.""" + incl_dom = {} + incl_glob = {"*working"} + incl_ent = {"binary_sensor.specificly_included"} + excl_dom = {} + excl_glob = {"*broken", "*notworking", "binary_sensor.*"} + excl_ent = {"light.ignoreme"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.working") is True + assert testfilter("sensor.notworking") is True # include is stronger + assert testfilter("sensor.broken") is False + assert testfilter("light.test") is False + assert testfilter("light.notworking") is True # include is stronger + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.not_working") is True # include is stronger + assert testfilter("binary_sensor.another") is False + assert testfilter("binary_sensor.specificly_included") is True + assert testfilter("sun.sun") is False + + +def test_exclude_domain_case5(): + """Test case 5 - include and exclude specified, with excluded domain.""" incl_dom = {} incl_ent = {"binary_sensor.working"} excl_dom = {"binary_sensor"} @@ -168,8 +237,8 @@ def test_exclude_domain_case4b(): assert testfilter("sun.sun") is True -def test_exclude_glob_case4b(): - """Test case 4b - include and exclude specified, with excluded glob.""" +def test_exclude_glob_case5(): + """Test case 5 - include and exclude specified, with excluded glob.""" incl_dom = {} incl_glob = {} incl_ent = {"binary_sensor.working"} @@ -189,8 +258,29 @@ def test_exclude_glob_case4b(): assert testfilter("sun.sun") is True -def test_no_domain_case4c(): - """Test case 4c - include and exclude specified, with no domains.""" +def test_exclude_glob_case5_include_strong(): + """Test case 5 - include and exclude specified, with excluded glob, and a specifically included entity.""" + incl_dom = {} + incl_glob = {} + incl_ent = {"binary_sensor.working"} + excl_dom = {"binary_sensor"} + excl_glob = {"binary_sensor.*"} + excl_ent = {"light.ignoreme", "sensor.notworking"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.test") + assert testfilter("sensor.notworking") is False + assert testfilter("light.test") + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.another") is False + assert testfilter("sun.sun") is True + + +def test_no_domain_case6(): + """Test case 6 - include and exclude specified, with no domains.""" incl_dom = {} incl_ent = {"binary_sensor.working"} excl_dom = {} @@ -303,3 +393,66 @@ def test_exlictly_included(): assert not filt.explicitly_excluded("switch.other") assert filt.explicitly_excluded("sensor.weather_5") assert filt.explicitly_excluded("light.kitchen") + + +def test_complex_include_exclude_filter(): + """Test a complex include exclude filter.""" + conf = { + "include": { + "domains": ["switch", "person"], + "entities": ["group.family"], + "entity_globs": [ + "sensor.*_sensor_temperature", + "sensor.*_actueel", + "sensor.*_totaal", + "sensor.calculated*", + "sensor.solaredge_*", + "sensor.speedtest*", + "sensor.teller*", + "sensor.zp*", + "binary_sensor.*_sensor_motion", + "binary_sensor.*_door", + "sensor.water_*ly", + "sensor.gas_*ly", + ], + }, + "exclude": { + "domains": [ + "alarm_control_panel", + "alert", + "automation", + "button", + "camera", + "climate", + "counter", + "cover", + "geo_location", + "group", + "input_boolean", + "input_datetime", + "input_number", + "input_select", + "input_text", + "light", + "media_player", + "number", + "proximity", + "remote", + "scene", + "script", + "sun", + "timer", + "updater", + "variable", + "weather", + "zone", + ], + "entities": [ + "sensor.solaredge_last_updatetime", + "sensor.solaredge_last_changed", + ], + "entity_globs": ["switch.*_light_level", "switch.sonos_*"], + }, + } + filt: EntityFilter = INCLUDE_EXCLUDE_FILTER_SCHEMA(conf) + assert filt("switch.espresso_keuken") is True diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 4968c872946..54c488690fa 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -1,10 +1,17 @@ """Test Home Assistant remote methods and classes.""" import datetime +import json +import time import pytest from homeassistant import core -from homeassistant.helpers.json import ExtendedJSONEncoder, JSONEncoder +from homeassistant.helpers.json import ( + ExtendedJSONEncoder, + JSONEncoder, + json_dumps, + json_dumps_sorted, +) from homeassistant.util import dt as dt_util @@ -64,3 +71,28 @@ def test_extended_json_encoder(hass): # Default method falls back to repr(o) o = object() assert ha_json_enc.default(o) == {"__type": str(type(o)), "repr": repr(o)} + + +def test_json_dumps_sorted(): + """Test the json dumps sorted function.""" + data = {"c": 3, "a": 1, "b": 2} + assert json_dumps_sorted(data) == json.dumps( + data, sort_keys=True, separators=(",", ":") + ) + + +def test_json_dumps_float_subclass(): + """Test the json dumps a float subclass.""" + + class FloatSubclass(float): + """A float subclass.""" + + assert json_dumps({"c": FloatSubclass(1.2)}) == '{"c":1.2}' + + +def test_json_dumps_tuple_subclass(): + """Test the json dumps a tuple subclass.""" + + tt = time.struct_time((1999, 3, 17, 32, 44, 55, 2, 76, 0)) + + assert json_dumps(tt) == "[1999,3,17,32,44,55,2,76,0]" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index a1fd3e73f59..fb57eff7685 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -18,6 +18,7 @@ from homeassistant.const import ( MASS_GRAMS, PRESSURE_PA, SPEED_KILOMETERS_PER_HOUR, + STATE_ON, TEMP_CELSIUS, VOLUME_LITERS, ) @@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import device_registry as dr, entity, template from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.json import json_dumps from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import UnitSystem @@ -294,6 +296,34 @@ def test_int_function(hass): assert render(hass, "{{ int('bad', default=1) }}") == 1 +def test_bool_function(hass): + """Test bool function.""" + assert render(hass, "{{ bool(true) }}") is True + assert render(hass, "{{ bool(false) }}") is False + assert render(hass, "{{ bool('on') }}") is True + assert render(hass, "{{ bool('off') }}") is False + with pytest.raises(TemplateError): + render(hass, "{{ bool('unknown') }}") + with pytest.raises(TemplateError): + render(hass, "{{ bool(none) }}") + assert render(hass, "{{ bool('unavailable', none) }}") is None + assert render(hass, "{{ bool('unavailable', default=none) }}") is None + + +def test_bool_filter(hass): + """Test bool filter.""" + assert render(hass, "{{ true | bool }}") is True + assert render(hass, "{{ false | bool }}") is False + assert render(hass, "{{ 'on' | bool }}") is True + assert render(hass, "{{ 'off' | bool }}") is False + with pytest.raises(TemplateError): + render(hass, "{{ 'unknown' | bool }}") + with pytest.raises(TemplateError): + render(hass, "{{ none | bool }}") + assert render(hass, "{{ 'unavailable' | bool(none) }}") is None + assert render(hass, "{{ 'unavailable' | bool(default=none) }}") is None + + @pytest.mark.parametrize( "value, expected", [ @@ -3831,3 +3861,21 @@ async def test_undefined_variable(hass, caplog): "Template variable warning: 'no_such_variable' is undefined when rendering '{{ no_such_variable }}'" in caplog.text ) + + +async def test_template_states_blocks_setitem(hass): + """Test we cannot setitem on TemplateStates.""" + hass.states.async_set("light.new", STATE_ON) + state = hass.states.get("light.new") + template_state = template.TemplateState(hass, state, True) + with pytest.raises(RuntimeError): + template_state["any"] = "any" + + +async def test_template_states_can_serialize(hass): + """Test TemplateState is serializable.""" + hass.states.async_set("light.new", STATE_ON) + state = hass.states.get("light.new") + template_state = template.TemplateState(hass, state, True) + assert template_state.as_dict() is template_state.as_dict() + assert json_dumps(template_state) == json_dumps(template_state) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 7023798f2b4..0d0970a4756 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -109,11 +109,29 @@ async def test_async_refresh(crd): await crd.async_refresh() assert updates == [2] - # Test unsubscribing through method - crd.async_add_listener(update_callback) - crd.async_remove_listener(update_callback) + +async def test_update_context(crd: update_coordinator.DataUpdateCoordinator[int]): + """Test update contexts for the update coordinator.""" await crd.async_refresh() - assert updates == [2] + assert not set(crd.async_contexts()) + + def update_callback1(): + pass + + def update_callback2(): + pass + + unsub1 = crd.async_add_listener(update_callback1, 1) + assert set(crd.async_contexts()) == {1} + + unsub2 = crd.async_add_listener(update_callback2, 2) + assert set(crd.async_contexts()) == {1, 2} + + unsub1() + assert set(crd.async_contexts()) == {2} + + unsub2() + assert not set(crd.async_contexts()) async def test_request_refresh(crd): @@ -191,7 +209,7 @@ async def test_update_interval(hass, crd): # Add subscriber update_callback = Mock() - crd.async_add_listener(update_callback) + unsub = crd.async_add_listener(update_callback) # Test twice we update with subscriber async_fire_time_changed(hass, utcnow() + crd.update_interval) @@ -203,7 +221,7 @@ async def test_update_interval(hass, crd): assert crd.data == 2 # Test removing listener - crd.async_remove_listener(update_callback) + unsub() async_fire_time_changed(hass, utcnow() + crd.update_interval) await hass.async_block_till_done() @@ -222,7 +240,7 @@ async def test_update_interval_not_present(hass, crd_without_update_interval): # Add subscriber update_callback = Mock() - crd.async_add_listener(update_callback) + unsub = crd.async_add_listener(update_callback) # Test twice we don't update with subscriber with no update interval async_fire_time_changed(hass, utcnow() + DEFAULT_UPDATE_INTERVAL) @@ -234,7 +252,7 @@ async def test_update_interval_not_present(hass, crd_without_update_interval): assert crd.data is None # Test removing listener - crd.async_remove_listener(update_callback) + unsub() async_fire_time_changed(hass, utcnow() + DEFAULT_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -253,9 +271,10 @@ async def test_refresh_recover(crd, caplog): assert "Fetching test data recovered" in caplog.text -async def test_coordinator_entity(crd): +async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[int]): """Test the CoordinatorEntity class.""" - entity = update_coordinator.CoordinatorEntity(crd) + context = object() + entity = update_coordinator.CoordinatorEntity(crd, context) assert entity.should_poll is False @@ -278,6 +297,8 @@ async def test_coordinator_entity(crd): await entity.async_update() assert entity.available is False + assert list(crd.async_contexts()) == [context] + async def test_async_set_updated_data(crd): """Test async_set_updated_data for update coordinator.""" diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 887f50fb628..edbcec27375 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -1,17 +1,21 @@ """Configuration for pylint tests.""" from importlib.machinery import SourceFileLoader +from pathlib import Path from types import ModuleType from pylint.checkers import BaseChecker from pylint.testutils.unittest_linter import UnittestLinter import pytest +BASE_PATH = Path(__file__).parents[2] -@pytest.fixture(name="hass_enforce_type_hints") + +@pytest.fixture(name="hass_enforce_type_hints", scope="session") def hass_enforce_type_hints_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" loader = SourceFileLoader( - "hass_enforce_type_hints", "pylint/plugins/hass_enforce_type_hints.py" + "hass_enforce_type_hints", + str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), ) return loader.load_module(None) diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index feb3b6b341c..8b4b8d4d058 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1,5 +1,6 @@ """Tests for pylint hass_enforce_type_hints plugin.""" # pylint:disable=protected-access +from __future__ import annotations import re from types import ModuleType @@ -14,6 +15,30 @@ import pytest from . import assert_adds_messages, assert_no_messages +@pytest.mark.parametrize( + ("module_name", "expected_platform", "in_platforms"), + [ + ("homeassistant", None, False), + ("homeassistant.components", None, False), + ("homeassistant.components.pylint_test", "__init__", False), + ("homeassistant.components.pylint_test.config_flow", "config_flow", False), + ("homeassistant.components.pylint_test.light", "light", True), + ("homeassistant.components.pylint_test.light.v1", None, False), + ], +) +def test_regex_get_module_platform( + hass_enforce_type_hints: ModuleType, + module_name: str, + expected_platform: str | None, + in_platforms: bool, +) -> None: + """Test _get_module_platform regex.""" + platform = hass_enforce_type_hints._get_module_platform(module_name) + + assert platform == expected_platform + assert (platform in hass_enforce_type_hints._PLATFORMS) == in_platforms + + @pytest.mark.parametrize( ("string", "expected_x", "expected_y", "expected_z", "expected_a"), [ @@ -91,11 +116,15 @@ def test_regex_a_or_b( """ ], ) -def test_ignore_not_annotations( +def test_ignore_no_annotations( hass_enforce_type_hints: ModuleType, type_hint_checker: BaseChecker, code: str ) -> None: """Ensure that _is_valid_type is not run if there are no annotations.""" - func_node = astroid.extract_node(code) + func_node = astroid.extract_node( + code, + "homeassistant.components.pylint_test", + ) + type_hint_checker.visit_module(func_node.parent) with patch.object( hass_enforce_type_hints, "_is_valid_type", return_value=True @@ -104,6 +133,41 @@ def test_ignore_not_annotations( is_valid_type.assert_not_called() +@pytest.mark.parametrize( + "code", + [ + """ + async def setup( #@ + arg1, arg2 + ): + pass + """ + ], +) +def test_bypass_ignore_no_annotations( + hass_enforce_type_hints: ModuleType, type_hint_checker: BaseChecker, code: str +) -> None: + """Test `ignore-missing-annotations` option. + + Ensure that `_is_valid_type` is run if there are no annotations + but `ignore-missing-annotations` option is forced to False. + """ + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + func_node = astroid.extract_node( + code, + "homeassistant.components.pylint_test", + ) + type_hint_checker.visit_module(func_node.parent) + + with patch.object( + hass_enforce_type_hints, "_is_valid_type", return_value=True + ) as is_valid_type: + type_hint_checker.visit_asyncfunctiondef(func_node) + is_valid_type.assert_called() + + @pytest.mark.parametrize( "code", [ @@ -131,7 +195,11 @@ def test_dont_ignore_partial_annotations( hass_enforce_type_hints: ModuleType, type_hint_checker: BaseChecker, code: str ) -> None: """Ensure that _is_valid_type is run if there is at least one annotation.""" - func_node = astroid.extract_node(code) + func_node = astroid.extract_node( + code, + "homeassistant.components.pylint_test", + ) + type_hint_checker.visit_module(func_node.parent) with patch.object( hass_enforce_type_hints, "_is_valid_type", return_value=True @@ -144,7 +212,6 @@ def test_invalid_discovery_info( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure invalid hints are rejected for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_tracker" func_node, discovery_info_node = astroid.extract_node( """ async def async_setup_scanner( #@ @@ -154,8 +221,10 @@ def test_invalid_discovery_info( discovery_info: dict[str, Any] | None = None, #@ ) -> bool: pass - """ + """, + "homeassistant.components.pylint_test.device_tracker", ) + type_hint_checker.visit_module(func_node.parent) with assert_adds_messages( linter, @@ -176,7 +245,6 @@ def test_valid_discovery_info( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure valid hints are accepted for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_tracker" func_node = astroid.extract_node( """ async def async_setup_scanner( #@ @@ -186,8 +254,10 @@ def test_valid_discovery_info( discovery_info: DiscoveryInfoType | None = None, ) -> bool: pass - """ + """, + "homeassistant.components.pylint_test.device_tracker", ) + type_hint_checker.visit_module(func_node.parent) with assert_no_messages(linter): type_hint_checker.visit_asyncfunctiondef(func_node) @@ -197,7 +267,6 @@ def test_invalid_list_dict_str_any( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure invalid hints are rejected for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_trigger" func_node = astroid.extract_node( """ async def async_get_triggers( #@ @@ -205,8 +274,10 @@ def test_invalid_list_dict_str_any( device_id: str ) -> list: pass - """ + """, + "homeassistant.components.pylint_test.device_trigger", ) + type_hint_checker.visit_module(func_node.parent) with assert_adds_messages( linter, @@ -227,7 +298,6 @@ def test_valid_list_dict_str_any( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure valid hints are accepted for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_trigger" func_node = astroid.extract_node( """ async def async_get_triggers( #@ @@ -235,8 +305,435 @@ def test_valid_list_dict_str_any( device_id: str ) -> list[dict[str, Any]]: pass - """ + """, + "homeassistant.components.pylint_test.device_trigger", ) + type_hint_checker.visit_module(func_node.parent) with assert_no_messages(linter): type_hint_checker.visit_asyncfunctiondef(func_node) + + +def test_invalid_config_flow_step( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure invalid hints are rejected for ConfigFlow step.""" + class_node, func_node, arg_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + async def async_step_zeroconf( #@ + self, + device_config: dict #@ + ): + pass + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=arg_node, + args=(2, "ZeroconfServiceInfo"), + line=10, + col_offset=8, + end_line=10, + end_col_offset=27, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="FlowResult", + line=8, + col_offset=4, + end_line=8, + end_col_offset=33, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +def test_valid_config_flow_step( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure valid hints are accepted for ConfigFlow step.""" + class_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + async def async_step_zeroconf( + self, + device_config: ZeroconfServiceInfo + ) -> FlowResult: + pass + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) + + +def test_invalid_config_flow_async_get_options_flow( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure invalid hints are rejected for ConfigFlow async_get_options_flow.""" + class_node, func_node, arg_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class AxisOptionsFlow(): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + def async_get_options_flow( #@ + config_entry #@ + ) -> AxisOptionsFlow: + return AxisOptionsFlow(config_entry) + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=arg_node, + args=(1, "ConfigEntry"), + line=12, + col_offset=8, + end_line=12, + end_col_offset=20, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="OptionsFlow", + line=11, + col_offset=4, + end_line=11, + end_col_offset=30, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +def test_valid_config_flow_async_get_options_flow( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure valid hints are accepted for ConfigFlow async_get_options_flow.""" + class_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class OptionsFlow(): + pass + + class AxisOptionsFlow(OptionsFlow): + pass + + class OtherOptionsFlow(OptionsFlow): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + def async_get_options_flow( + config_entry: ConfigEntry + ) -> AxisOptionsFlow | OtherOptionsFlow | OptionsFlow: + if self.use_other: + return OtherOptionsFlow(config_entry) + return AxisOptionsFlow(config_entry) + + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) + + +def test_invalid_entity_properties( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Check missing entity properties when ignore_missing_annotations is False.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node, prop_node, func_node = astroid.extract_node( + """ + class LockEntity(): + pass + + class DoorLock( #@ + LockEntity + ): + @property + def changed_by( #@ + self + ): + pass + + async def async_lock( #@ + self, + **kwargs + ) -> bool: + pass + """, + "homeassistant.components.pylint_test.lock", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=prop_node, + args=["str", None], + line=9, + col_offset=4, + end_line=9, + end_col_offset=18, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=func_node, + args=("kwargs", "Any"), + line=14, + col_offset=4, + end_line=14, + end_col_offset=24, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="None", + line=14, + col_offset=4, + end_line=14, + end_col_offset=24, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +def test_ignore_invalid_entity_properties( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Check invalid entity properties are ignored by default.""" + class_node = astroid.extract_node( + """ + class LockEntity(): + pass + + class DoorLock( #@ + LockEntity + ): + @property + def changed_by( + self + ): + pass + + async def async_lock( + self, + **kwargs + ) -> bool: + pass + """, + "homeassistant.components.pylint_test.lock", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) + + +def test_named_arguments( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Check missing entity properties when ignore_missing_annotations is False.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node, func_node, percentage_node, preset_mode_node = astroid.extract_node( + """ + class FanEntity(): + pass + + class MyFan( #@ + FanEntity + ): + async def async_turn_on( #@ + self, + percentage, #@ + *, + preset_mode: str, #@ + **kwargs + ) -> bool: + pass + """, + "homeassistant.components.pylint_test.fan", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=percentage_node, + args=("percentage", "int | None"), + line=10, + col_offset=8, + end_line=10, + end_col_offset=18, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=preset_mode_node, + args=("preset_mode", "str | None"), + line=12, + col_offset=8, + end_line=12, + end_col_offset=24, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=func_node, + args=("kwargs", "Any"), + line=8, + col_offset=4, + end_line=8, + end_col_offset=27, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="None", + line=8, + col_offset=4, + end_line=8, + end_col_offset=27, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +@pytest.mark.parametrize( + "return_hint", + [ + "", + "-> Mapping[int, int]", + "-> dict[int, Any]", + ], +) +def test_invalid_mapping_return_type( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + return_hint: str, +) -> None: + """Check that Mapping[xxx, Any] doesn't accept invalid Mapping or dict.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node, property_node = astroid.extract_node( + f""" + class Entity(): + pass + + class ToggleEntity(Entity): + pass + + class FanEntity(ToggleEntity): + pass + + class MyFanA( #@ + FanEntity + ): + @property + def capability_attributes( #@ + self + ){return_hint}: + pass + """, + "homeassistant.components.pylint_test.fan", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=property_node, + args=["Mapping[str, Any]", None], + line=15, + col_offset=4, + end_line=15, + end_col_offset=29, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +@pytest.mark.parametrize( + "return_hint", + [ + "-> Mapping[str, Any]", + "-> Mapping[str, bool | int]", + "-> dict[str, Any]", + "-> dict[str, str]", + ], +) +def test_valid_mapping_return_type( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + return_hint: str, +) -> None: + """Check that Mapping[xxx, Any] accepts both Mapping and dict.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node = astroid.extract_node( + f""" + class Entity(): + pass + + class ToggleEntity(Entity): + pass + + class FanEntity(ToggleEntity): + pass + + class MyFanA( #@ + FanEntity + ): + @property + def capability_attributes( + self + ){return_hint}: + pass + """, + "homeassistant.components.pylint_test.fan", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c6c507a0f73..232d8fb6bbf 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -84,7 +84,7 @@ async def test_load_hassio(hass): with patch.dict(os.environ, {}, clear=True): assert bootstrap._get_domains(hass, {}) == set() - with patch.dict(os.environ, {"HASSIO": "1"}): + with patch.dict(os.environ, {"SUPERVISOR": "1"}): assert bootstrap._get_domains(hass, {}) == {"hassio"} diff --git a/tests/test_config.py b/tests/test_config.py index 552139fa0ef..9b3f9d8755f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ """Test config utils.""" # pylint: disable=protected-access from collections import OrderedDict +import contextlib import copy import os from unittest import mock @@ -147,7 +148,7 @@ def test_load_yaml_config_raises_error_if_not_dict(): def test_load_yaml_config_raises_error_if_malformed_yaml(): """Test error raised if invalid YAML.""" with open(YAML_PATH, "w") as fp: - fp.write(":") + fp.write(":-") with pytest.raises(HomeAssistantError): config_util.load_yaml_config_file(YAML_PATH) @@ -156,11 +157,22 @@ def test_load_yaml_config_raises_error_if_malformed_yaml(): def test_load_yaml_config_raises_error_if_unsafe_yaml(): """Test error raised if unsafe YAML.""" with open(YAML_PATH, "w") as fp: - fp.write("hello: !!python/object/apply:os.system") + fp.write("- !!python/object/apply:os.system []") - with pytest.raises(HomeAssistantError): + with patch.object(os, "system") as system_mock, contextlib.suppress( + HomeAssistantError + ): config_util.load_yaml_config_file(YAML_PATH) + assert len(system_mock.mock_calls) == 0 + + # Here we validate that the test above is a good test + # since previously the syntax was not valid + with open(YAML_PATH) as fp, patch.object(os, "system") as system_mock: + list(yaml.unsafe_load_all(fp)) + + assert len(system_mock.mock_calls) == 1 + def test_load_yaml_config_preserves_key_order(): """Test removal of library.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 2602887d1d5..9372a906f71 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1,4 +1,6 @@ """Test the config manager.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -7,14 +9,15 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant import config_entries, data_entry_flow, loader +from homeassistant.components import dhcp from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, Event, callback -from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, BaseServiceInfo +from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, BaseServiceInfo, FlowResult from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -1644,7 +1647,7 @@ async def test_unique_id_update_existing_entry_without_reload(hass, manager): async def async_step_user(self, user_input=None): """Test user step.""" await self.async_set_unique_id("mock-unique-id") - await self._abort_if_unique_id_configured( + self._abort_if_unique_id_configured( updates={"host": "1.1.1.1"}, reload_on_update=False ) @@ -1725,6 +1728,75 @@ async def test_unique_id_update_existing_entry_with_reload(hass, manager): assert len(async_reload.mock_calls) == 0 +async def test_unique_id_from_discovery_in_setup_retry(hass, manager): + """Test that we reload when in a setup retry state from discovery.""" + hass.config.components.add("comp") + unique_id = "34:ea:34:b4:3b:5a" + host = "0.0.0.0" + entry = MockConfigEntry( + domain="comp", + data={"additional": "data", "host": host}, + unique_id=unique_id, + state=config_entries.ConfigEntryState.SETUP_RETRY, + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule("comp"), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> FlowResult: + """Test dhcp step.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Test user step.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Verify we do not reload from a user source + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload: + result = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert len(async_reload.mock_calls) == 0 + + # Verify do reload from a discovery source + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload: + discovery_result = await manager.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="any", + ip=host, + macaddress=unique_id, + ), + ) + await hass.async_block_till_done() + + assert discovery_result["type"] == RESULT_TYPE_ABORT + assert discovery_result["reason"] == "already_configured" + assert len(async_reload.mock_calls) == 1 + + async def test_unique_id_not_update_existing_entry(hass, manager): """Test that we do not update an entry if existing entry has the data.""" hass.config.components.add("comp") @@ -3118,3 +3190,85 @@ async def test_entry_reload_concurrency(hass, manager): await asyncio.gather(*tasks) assert entry.state is config_entries.ConfigEntryState.LOADED assert loaded == 1 + + +async def test_unique_id_update_while_setup_in_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test we handle the case where the config entry is updated while setup is in progress.""" + + async def mock_setup_entry(hass, entry): + """Mock setting up entry.""" + await asyncio.sleep(0.1) + return True + + async def mock_unload_entry(hass, entry): + """Mock unloading an entry.""" + return True + + hass.config.components.add("comp") + entry = MockConfigEntry( + domain="comp", + data={"additional": "data", "host": "0.0.0.0"}, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.SETUP_RETRY, + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + updates = {"host": "1.1.1.1"} + + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + await asyncio.sleep(0) + assert entry.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + await self._abort_if_unique_id_configured( + updates=updates, reload_on_update=True + ) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload: + result = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data["host"] == "1.1.1.1" + assert entry.data["additional"] == "data" + + # Setup is already in progress, we should not reload + # if it fails it will go into a retry state and try again + assert len(async_reload.mock_calls) == 0 + await hass.async_block_till_done() + assert entry.state is config_entries.ConfigEntryState.LOADED + + +async def test_disallow_entry_reload_with_setup_in_progresss(hass, manager): + """Test we do not allow reload while the config entry is still setting up.""" + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.SETUP_IN_PROGRESS + ) + entry.add_to_hass(hass) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_reload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 7e4dba42c2f..77499707489 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -213,16 +213,9 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" - assert len(mock_is_installed.mock_calls) == 1 - assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ - "test-comp==1.0.0", - ] - + assert len(mock_is_installed.mock_calls) == 0 # On another attempt we remember failures and don't try again - assert len(mock_inst.mock_calls) == 1 - assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ - "test-comp==1.0.0" - ] + assert len(mock_inst.mock_calls) == 0 # Now clear the history and so we try again async_clear_install_history(hass) @@ -239,14 +232,13 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" - assert len(mock_is_installed.mock_calls) == 3 + assert len(mock_is_installed.mock_calls) == 2 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] - assert len(mock_inst.mock_calls) == 7 + assert len(mock_inst.mock_calls) == 6 assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-after-dep==1.0.0", @@ -254,7 +246,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha "test-comp-dep==1.0.0", "test-comp-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] # Now clear the history and mock success @@ -272,18 +263,16 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" - assert len(mock_is_installed.mock_calls) == 3 + assert len(mock_is_installed.mock_calls) == 2 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] - assert len(mock_inst.mock_calls) == 3 + assert len(mock_inst.mock_calls) == 2 assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] @@ -408,12 +397,12 @@ async def test_discovery_requirements_mqtt(hass): hass, MockModule("mqtt_comp", partial_manifest={"mqtt": ["foo/discovery"]}) ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "mqtt_comp") assert len(mock_process.mock_calls) == 2 # mqtt also depends on http - assert mock_process.mock_calls[0][1][2] == mqtt.requirements + assert mock_process.mock_calls[0][1][1] == mqtt.requirements async def test_discovery_requirements_ssdp(hass): @@ -425,17 +414,17 @@ async def test_discovery_requirements_ssdp(hass): hass, MockModule("ssdp_comp", partial_manifest={"ssdp": [{"st": "roku:ecp"}]}) ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "ssdp_comp") assert len(mock_process.mock_calls) == 4 - assert mock_process.mock_calls[0][1][2] == ssdp.requirements + assert mock_process.mock_calls[0][1][1] == ssdp.requirements # Ensure zeroconf is a dep for ssdp assert { - mock_process.mock_calls[1][1][1], - mock_process.mock_calls[2][1][1], - mock_process.mock_calls[3][1][1], + mock_process.mock_calls[1][1][0], + mock_process.mock_calls[2][1][0], + mock_process.mock_calls[3][1][0], } == {"network", "zeroconf", "http"} @@ -454,12 +443,12 @@ async def test_discovery_requirements_zeroconf(hass, partial_manifest): ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "comp") assert len(mock_process.mock_calls) == 3 # zeroconf also depends on http - assert mock_process.mock_calls[0][1][2] == zeroconf.requirements + assert mock_process.mock_calls[0][1][1] == zeroconf.requirements async def test_discovery_requirements_dhcp(hass): @@ -477,9 +466,9 @@ async def test_discovery_requirements_dhcp(hass): ), ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "comp") assert len(mock_process.mock_calls) == 1 # dhcp does not depend on http - assert mock_process.mock_calls[0][1][2] == dhcp.requirements + assert mock_process.mock_calls[0][1][1] == dhcp.requirements diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d69c6d7c290..9ed47109210 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -2,7 +2,6 @@ import asyncio from contextlib import contextmanager from http import HTTPStatus -import json as _json import re from unittest import mock from urllib.parse import parse_qs @@ -14,6 +13,7 @@ from multidict import CIMultiDict from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE +from homeassistant.helpers.json import json_dumps, json_loads RETYPE = type(re.compile("")) @@ -169,7 +169,7 @@ class AiohttpClientMockResponse: ): """Initialize a fake response.""" if json is not None: - text = _json.dumps(json) + text = json_dumps(json) if text is not None: response = text.encode("utf-8") if response is None: @@ -252,9 +252,9 @@ class AiohttpClientMockResponse: """Return mock response as a string.""" return self.response.decode(encoding, errors=errors) - async def json(self, encoding="utf-8", content_type=None): + async def json(self, encoding="utf-8", content_type=None, loads=json_loads): """Return mock response as a json.""" - return _json.loads(self.response.decode(encoding)) + return loads(self.response.decode(encoding)) def release(self): """Mock release.""" diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index edd8965e4e9..98d59a473b1 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -18,7 +18,7 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O from tests.common import MockEntity -ENTITIES = {} +ENTITIES = [] def init(empty=False): diff --git a/tests/testing_config/custom_components/test/number.py b/tests/testing_config/custom_components/test/number.py index 93d7783d684..094698923f4 100644 --- a/tests/testing_config/custom_components/test/number.py +++ b/tests/testing_config/custom_components/test/number.py @@ -3,7 +3,7 @@ Provide a mock number platform. Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, RestoreNumber from tests.common import MockEntity @@ -13,10 +13,72 @@ ENTITIES = [] class MockNumberEntity(MockEntity, NumberEntity): - """Mock Select class.""" + """Mock number class.""" - _attr_value = 50.0 - _attr_step = 1.0 + @property + def native_max_value(self): + """Return the native native_max_value.""" + return self._handle("native_max_value") + + @property + def native_min_value(self): + """Return the native native_min_value.""" + return self._handle("native_min_value") + + @property + def native_step(self): + """Return the native native_step.""" + return self._handle("native_step") + + @property + def native_unit_of_measurement(self): + """Return the native unit_of_measurement.""" + return self._handle("native_unit_of_measurement") + + @property + def native_value(self): + """Return the native value of this number.""" + return self._handle("native_value") + + def set_native_value(self, value: float) -> None: + """Change the selected option.""" + self._values["native_value"] = value + + +class MockRestoreNumber(MockNumberEntity, RestoreNumber): + """Mock RestoreNumber class.""" + + async def async_added_to_hass(self) -> None: + """Restore native_*.""" + await super().async_added_to_hass() + if (last_number_data := await self.async_get_last_number_data()) is None: + return + self._values["native_max_value"] = last_number_data.native_max_value + self._values["native_min_value"] = last_number_data.native_min_value + self._values["native_step"] = last_number_data.native_step + self._values[ + "native_unit_of_measurement" + ] = last_number_data.native_unit_of_measurement + self._values["native_value"] = last_number_data.native_value + + +class LegacyMockNumberEntity(MockEntity, NumberEntity): + """Mock Number class using deprecated features.""" + + @property + def max_value(self): + """Return the native max_value.""" + return self._handle("max_value") + + @property + def min_value(self): + """Return the native min_value.""" + return self._handle("min_value") + + @property + def step(self): + """Return the native step.""" + return self._handle("step") @property def value(self): @@ -25,7 +87,7 @@ class MockNumberEntity(MockEntity, NumberEntity): def set_value(self, value: float) -> None: """Change the selected option.""" - self._attr_value = value + self._values["value"] = value def init(empty=False): @@ -39,6 +101,7 @@ def init(empty=False): MockNumberEntity( name="test", unique_id=UNIQUE_NUMBER, + native_value=50.0, ), ] ) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 224d6495548..23a9569c785 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -6,6 +6,11 @@ Call init before using it in your tests to ensure clean test data. from __future__ import annotations from homeassistant.components.weather import ( + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, @@ -37,6 +42,80 @@ async def async_setup_platform( class MockWeather(MockEntity, WeatherEntity): """Mock weather class.""" + @property + def native_temperature(self) -> float | None: + """Return the platform temperature.""" + return self._handle("native_temperature") + + @property + def native_temperature_unit(self) -> str | None: + """Return the unit of measurement for temperature.""" + return self._handle("native_temperature_unit") + + @property + def native_pressure(self) -> float | None: + """Return the pressure.""" + return self._handle("native_pressure") + + @property + def native_pressure_unit(self) -> str | None: + """Return the unit of measurement for pressure.""" + return self._handle("native_pressure_unit") + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return self._handle("humidity") + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("native_wind_speed") + + @property + def native_wind_speed_unit(self) -> str | None: + """Return the unit of measurement for wind speed.""" + return self._handle("native_wind_speed_unit") + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return self._handle("wind_bearing") + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return self._handle("ozone") + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return self._handle("native_visibility") + + @property + def native_visibility_unit(self) -> str | None: + """Return the unit of measurement for visibility.""" + return self._handle("native_visibility_unit") + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self._handle("forecast") + + @property + def native_precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._handle("native_precipitation_unit") + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._handle("condition") + + +class MockWeatherCompat(MockEntity, WeatherEntity): + """Mock weather class for backwards compatibility check.""" + @property def temperature(self) -> float | None: """Return the platform temperature.""" @@ -99,7 +178,7 @@ class MockWeather(MockEntity, WeatherEntity): @property def precipitation_unit(self) -> str | None: - """Return the native unit of measurement for accumulated precipitation.""" + """Return the unit of measurement for accumulated precipitation.""" return self._handle("precipitation_unit") @property @@ -111,6 +190,26 @@ class MockWeather(MockEntity, WeatherEntity): class MockWeatherMockForecast(MockWeather): """Mock weather class with mocked forecast.""" + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + } + ] + + +class MockWeatherMockForecastCompat(MockWeatherCompat): + """Mock weather class with mocked forecast for compatibility check.""" + @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 461d94d0c67..9974cbb9628 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -11,6 +11,7 @@ import pytest from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.template import TupleWrapper from homeassistant.util.json import ( SerializationError, find_paths_unserializable_data, @@ -51,6 +52,14 @@ def test_save_and_load(): assert data == TEST_JSON_A +def test_save_and_load_int_keys(): + """Test saving and loading back stringifies the keys.""" + fname = _path_for("test1") + save_json(fname, {1: "a", 2: "b"}) + data = load_json(fname) + assert data == {"1": "a", "2": "b"} + + def test_save_and_load_private(): """Test we can load private files and that they are protected.""" fname = _path_for("test2") @@ -72,7 +81,7 @@ def test_overwrite_and_reload(atomic_writes): def test_save_bad_data(): - """Test error from trying to save unserialisable data.""" + """Test error from trying to save unserializable data.""" with pytest.raises(SerializationError) as excinfo: save_json("test4", {"hello": set()}) @@ -82,6 +91,17 @@ def test_save_bad_data(): ) +def test_save_bad_data_tuple_wrapper(): + """Test error from trying to save unserializable data.""" + with pytest.raises(SerializationError) as excinfo: + save_json("test4", {"hello": TupleWrapper(("4", "5"))}) + + assert ( + "Failed to serialize to JSON: test4. Bad data at $.hello=('4', '5')(" + in str(excinfo.value) + ) + + def test_load_bad_data(): """Test error from trying to load unserialisable data.""" fname = _path_for("test5") diff --git a/tests/util/test_network.py b/tests/util/test_network.py index 4f372e5e1a7..7339b6dc51d 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -30,7 +30,9 @@ def test_is_private(): def test_is_link_local(): """Test link local addresses.""" assert network_util.is_link_local(ip_address("169.254.12.3")) + assert network_util.is_link_local(ip_address("fe80::1234:5678:abcd")) assert not network_util.is_link_local(ip_address("127.0.0.1")) + assert not network_util.is_link_local(ip_address("::1")) def test_is_invalid(): @@ -43,7 +45,13 @@ def test_is_local(): """Test local addresses.""" assert network_util.is_local(ip_address("192.168.0.1")) assert network_util.is_local(ip_address("127.0.0.1")) + assert network_util.is_local(ip_address("fd12:3456:789a:1::1")) + assert network_util.is_local(ip_address("fe80::1234:5678:abcd")) + assert network_util.is_local(ip_address("::ffff:192.168.0.1")) assert not network_util.is_local(ip_address("208.5.4.2")) + assert not network_util.is_local(ip_address("198.51.100.1")) + assert not network_util.is_local(ip_address("2001:DB8:FA1::1")) + assert not network_util.is_local(ip_address("::ffff:208.5.4.2")) def test_is_ip_address(): diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py index 7f52c67ed50..9c7fd070313 100644 --- a/tests/util/test_speed.py +++ b/tests/util/test_speed.py @@ -2,9 +2,11 @@ import pytest from homeassistant.const import ( + SPEED_FEET_PER_SECOND, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_MILLIMETERS_PER_DAY, @@ -59,8 +61,12 @@ def test_convert_nonnumeric_value(): (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 (5, SPEED_METERS_PER_SECOND, 708661, SPEED_INCHES_PER_HOUR), - # 5000 in/hr / 39.3701 in/m / 3600 s/hr = 0.03528 m/s + # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s (5000, SPEED_INCHES_PER_HOUR, 0.03528, SPEED_METERS_PER_SECOND), + # 5 kt * 1852 m/nmi / 3600 s/h = 2.5722 m/s + (5, SPEED_KNOTS, 2.5722, SPEED_METERS_PER_SECOND), + # 5 ft/s * 0.3048 m/ft = 1.524 m/s + (5, SPEED_FEET_PER_SECOND, 1.524, SPEED_METERS_PER_SECOND), ], ) def test_convert_different_units(from_value, from_unit, expected, to_unit): diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 2b86b3c50e9..11dc40233dc 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,10 +1,12 @@ """Test Home Assistant yaml loader.""" +import importlib import io import os import unittest from unittest.mock import patch import pytest +import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.exceptions import HomeAssistantError @@ -14,7 +16,41 @@ from homeassistant.util.yaml import loader as yaml_loader from tests.common import get_test_config_dir, patch_yaml_files -def test_simple_list(): +@pytest.fixture(params=["enable_c_loader", "disable_c_loader"]) +def try_both_loaders(request): + """Disable the yaml c loader.""" + if not request.param == "disable_c_loader": + yield + return + try: + cloader = pyyaml.CSafeLoader + except ImportError: + return + del pyyaml.CSafeLoader + importlib.reload(yaml_loader) + yield + pyyaml.CSafeLoader = cloader + importlib.reload(yaml_loader) + + +@pytest.fixture(params=["enable_c_dumper", "disable_c_dumper"]) +def try_both_dumpers(request): + """Disable the yaml c dumper.""" + if not request.param == "disable_c_dumper": + yield + return + try: + cdumper = pyyaml.CSafeDumper + except ImportError: + return + del pyyaml.CSafeDumper + importlib.reload(yaml_loader) + yield + pyyaml.CSafeDumper = cdumper + importlib.reload(yaml_loader) + + +def test_simple_list(try_both_loaders): """Test simple list.""" conf = "config:\n - simple\n - list" with io.StringIO(conf) as file: @@ -22,7 +58,7 @@ def test_simple_list(): assert doc["config"] == ["simple", "list"] -def test_simple_dict(): +def test_simple_dict(try_both_loaders): """Test simple dict.""" conf = "key: value" with io.StringIO(conf) as file: @@ -37,14 +73,14 @@ def test_unhashable_key(): load_yaml_config_file(YAML_CONFIG_FILE) -def test_no_key(): +def test_no_key(try_both_loaders): """Test item without a key.""" files = {YAML_CONFIG_FILE: "a: a\nnokeyhere"} with pytest.raises(HomeAssistantError), patch_yaml_files(files): yaml.load_yaml(YAML_CONFIG_FILE) -def test_environment_variable(): +def test_environment_variable(try_both_loaders): """Test config file with environment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" @@ -54,7 +90,7 @@ def test_environment_variable(): del os.environ["PASSWORD"] -def test_environment_variable_default(): +def test_environment_variable_default(try_both_loaders): """Test config file with default value for environment variable.""" conf = "password: !env_var PASSWORD secret_password" with io.StringIO(conf) as file: @@ -62,14 +98,14 @@ def test_environment_variable_default(): assert doc["password"] == "secret_password" -def test_invalid_environment_variable(): +def test_invalid_environment_variable(try_both_loaders): """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with pytest.raises(HomeAssistantError), io.StringIO(conf) as file: yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) -def test_include_yaml(): +def test_include_yaml(try_both_loaders): """Test include yaml.""" with patch_yaml_files({"test.yaml": "value"}): conf = "key: !include test.yaml" @@ -85,7 +121,7 @@ def test_include_yaml(): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_list(mock_walk): +def test_include_dir_list(mock_walk, try_both_loaders): """Test include dir list yaml.""" mock_walk.return_value = [["/test", [], ["two.yaml", "one.yaml"]]] @@ -97,7 +133,7 @@ def test_include_dir_list(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_list_recursive(mock_walk): +def test_include_dir_list_recursive(mock_walk, try_both_loaders): """Test include dir recursive list yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["zero.yaml"]], @@ -124,7 +160,7 @@ def test_include_dir_list_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_named(mock_walk): +def test_include_dir_named(mock_walk, try_both_loaders): """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", [], ["first.yaml", "second.yaml", "secrets.yaml"]] @@ -139,7 +175,7 @@ def test_include_dir_named(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_named_recursive(mock_walk): +def test_include_dir_named_recursive(mock_walk, try_both_loaders): """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -167,7 +203,7 @@ def test_include_dir_named_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_list(mock_walk): +def test_include_dir_merge_list(mock_walk, try_both_loaders): """Test include dir merge list yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -181,7 +217,7 @@ def test_include_dir_merge_list(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_list_recursive(mock_walk): +def test_include_dir_merge_list_recursive(mock_walk, try_both_loaders): """Test include dir merge list yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -208,7 +244,7 @@ def test_include_dir_merge_list_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_named(mock_walk): +def test_include_dir_merge_named(mock_walk, try_both_loaders): """Test include dir merge named yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -225,7 +261,7 @@ def test_include_dir_merge_named(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_named_recursive(mock_walk): +def test_include_dir_merge_named_recursive(mock_walk, try_both_loaders): """Test include dir merge named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -257,19 +293,19 @@ def test_include_dir_merge_named_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.open", create=True) -def test_load_yaml_encoding_error(mock_open): +def test_load_yaml_encoding_error(mock_open, try_both_loaders): """Test raising a UnicodeDecodeError.""" mock_open.side_effect = UnicodeDecodeError("", b"", 1, 0, "") with pytest.raises(HomeAssistantError): yaml_loader.load_yaml("test") -def test_dump(): +def test_dump(try_both_dumpers): """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "b"}) == "a:\nb: b\n" -def test_dump_unicode(): +def test_dump_unicode(try_both_dumpers): """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" @@ -405,7 +441,7 @@ class TestSecrets(unittest.TestCase): ) -def test_representing_yaml_loaded_data(): +def test_representing_yaml_loaded_data(try_both_dumpers): """Test we can represent YAML loaded data.""" files = {YAML_CONFIG_FILE: 'key: [1, "2", 3]'} with patch_yaml_files(files): @@ -413,7 +449,7 @@ def test_representing_yaml_loaded_data(): assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n" -def test_duplicate_key(caplog): +def test_duplicate_key(caplog, try_both_loaders): """Test duplicate dict keys.""" files = {YAML_CONFIG_FILE: "key: thing1\nkey: thing2"} with patch_yaml_files(files): @@ -421,7 +457,7 @@ def test_duplicate_key(caplog): assert "contains duplicate key" in caplog.text -def test_no_recursive_secrets(caplog): +def test_no_recursive_secrets(caplog, try_both_loaders): """Test that loading of secrets from the secrets file fails correctly.""" files = {YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"} with patch_yaml_files(files), pytest.raises(HomeAssistantError) as e: @@ -441,7 +477,16 @@ def test_input_class(): assert len({input, input2}) == 1 -def test_input(): +def test_input(try_both_loaders, try_both_dumpers): """Test loading inputs.""" data = {"hello": yaml.Input("test_name")} assert yaml.parse_yaml(yaml.dump(data)) == data + + +@pytest.mark.skipif( + not os.environ.get("HASS_CI"), + reason="This test validates that the CI has the C loader available", +) +def test_c_loader_is_available_in_ci(): + """Verify we are testing the C loader in the CI.""" + assert yaml.loader.HAS_C_LOADER is True